@333eco/corpus 1.0.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/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@333eco/corpus",
3
+ "version": "1.0.0",
4
+ "description": "An MCP server for an open-licensed corpus, served with verifiable provenance — every document carries its sha256, DOI and OpenTimestamps proof so a consuming agent can check its own citation.",
5
+ "license": "CC0-1.0",
6
+ "author": "Thon Ly",
7
+ "homepage": "https://thonly.org/research",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/333eco/corpus.333.eco.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/333eco/corpus.333.eco/issues"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "corpus",
19
+ "provenance",
20
+ "cc0",
21
+ "opentimestamps",
22
+ "doi",
23
+ "verifiable"
24
+ ],
25
+ "type": "module",
26
+ "bin": {
27
+ "corpus-mcp": "src/server.mjs"
28
+ },
29
+ "exports": {
30
+ "./corpus.json": "./dist/corpus.json",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "dist"
39
+ ],
40
+ "scripts": {
41
+ "start": "node src/server.mjs",
42
+ "build": "node scripts/build-index.mjs --from ../../../TH/publications ../../../H3/publications",
43
+ "check": "node scripts/build-index.mjs --check --from ../../../TH/publications ../../../H3/publications"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // An MCP server over stdio for the open-licensed corpus.
4
+ //
5
+ // npx @333eco/corpus (or: node src/server.mjs)
6
+ //
7
+ // ⭐ WHAT MAKES THIS DIFFERENT FROM A DOCUMENT SERVER. Every response carries a
8
+ // PROVENANCE ENVELOPE — sha256, DOI, Zenodo record, whether an OpenTimestamps
9
+ // proof exists, and whether the current text still matches what was deposited.
10
+ // A retrieval server normally asks to be believed. This one hands over the means
11
+ // to check it, and the means are only worth anything because the anchors are
12
+ // already years old.
13
+ //
14
+ // The consequence for an agent citing this corpus: it can verify the passage it
15
+ // is about to quote instead of trusting the transport it arrived over.
16
+ //
17
+ // ⛔ THE LICENCE GATE IS UPSTREAM, IN THE INDEX. This file cannot serve an
18
+ // unlicensed document because build-index.mjs cannot put one in dist/corpus.json.
19
+ // That is deliberate — a gate in the request path is a rule that a future
20
+ // refactor can route around; a gate in the artifact is a property. Do not add a
21
+ // filesystem read here, ever: the moment this process can open a .md itself, the
22
+ // gate stops being structural.
23
+ //
24
+ // ⚠️ NO DEPENDENCIES, INCLUDING NO MCP SDK. MCP over stdio is newline-delimited
25
+ // JSON-RPC 2.0, which is a few hundred lines to speak correctly, and this estate's
26
+ // standing rule is node built-ins only. The cost is that protocol revisions have
27
+ // to be tracked by hand; PROTOCOL_VERSIONS below is where that lives.
28
+ //
29
+ // ⚠️ STDOUT IS THE PROTOCOL. Never console.log for diagnostics — a stray line
30
+ // corrupts the stream and the client fails with a parse error that names nothing.
31
+ // Diagnostics go to stderr, which clients surface as server logs.
32
+
33
+ import { readFileSync, existsSync } from "node:fs";
34
+ import { dirname, join, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { createInterface } from "node:readline";
37
+
38
+ const HERE = dirname(fileURLToPath(import.meta.url));
39
+ const INDEX = resolve(HERE, "..", "dist", "corpus.json");
40
+
41
+ const log = (...a) => console.error("[corpus-mcp]", ...a);
42
+
43
+ if (!existsSync(INDEX)) {
44
+ log("dist/corpus.json is missing. Build it: node scripts/build-index.mjs --from <corpus repos>");
45
+ process.exit(1);
46
+ }
47
+
48
+ const corpus = JSON.parse(readFileSync(INDEX, "utf8"));
49
+ const bySlug = new Map(corpus.documents.map((d) => [d.slug, d]));
50
+ log(`${corpus.document_count} documents loaded —`, JSON.stringify(corpus.licences));
51
+
52
+ /* ------------------------------------------------------- protocol plumbing ---
53
+ Versions this server knows how to speak, newest first. On initialize the spec
54
+ has the server answer with the version it WILL use: echo the client's if we
55
+ know it, otherwise offer our newest and let the client decide. */
56
+
57
+ const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
58
+
59
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
60
+ const result = (id, value) => send({ jsonrpc: "2.0", id, result: value });
61
+ const failure = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
62
+
63
+ /* ------------------------------------------------------------- the envelope ---
64
+ Attached to every document a tool returns. `verify` is the instruction rather
65
+ than a promise: it tells the agent exactly what to run, so the claim is
66
+ checkable without trusting this sentence either. */
67
+
68
+ const envelope = (d) => ({
69
+ slug: d.slug,
70
+ title: d.title,
71
+ licence: {
72
+ id: d.licence.id,
73
+ url: d.licence.url,
74
+ attribution_required: d.licence.attribution_required,
75
+ // Only present where it is actually owed, so an agent can act on the
76
+ // field's presence rather than parsing the licence id.
77
+ ...(d.licence.attribution_required && d.authors ? { attribute_to: d.authors } : {})
78
+ },
79
+ provenance: {
80
+ ...d.provenance,
81
+ verify: {
82
+ sha256: `printf '%s' "$(cat <file>)" | shasum -a 256 # compare to provenance.sha256, computed over the FULL source file including its metadata block`,
83
+ ...(d.provenance.doi ? { doi: `https://doi.org/${d.provenance.doi}` } : {}),
84
+ ...(d.provenance.opentimestamps
85
+ ? { opentimestamps: `ots verify ${d.path}.ots # in the source repository; the proof is anchored in the Bitcoin blockchain` }
86
+ : {}),
87
+ ...(d.provenance.deposited_matches_current === false
88
+ ? {
89
+ note:
90
+ "This document has been REVISED since its Zenodo deposit, so provenance.sha256 " +
91
+ "and provenance.deposited_sha256 differ legitimately. The DOI resolves to the " +
92
+ "deposited version; the text served here is newer. Cite accordingly."
93
+ }
94
+ : {})
95
+ }
96
+ }
97
+ });
98
+
99
+ /* -------------------------------------------------------------------- tools --- */
100
+
101
+ const TOOLS = [
102
+ {
103
+ name: "search_corpus",
104
+ description:
105
+ "Full-text search across the open-licensed corpus. Returns matching documents with a provenance envelope " +
106
+ "and a short excerpt around each match — not the full text; call get_document for that. Every result can " +
107
+ "be independently verified via its sha256, DOI and OpenTimestamps proof.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ query: { type: "string", description: "Text to search for. Case-insensitive." },
112
+ genre: { type: "string", description: "Optional: restrict to a genre, e.g. essays, defensive-publications, positions, white-papers." },
113
+ limit: { type: "number", description: "Maximum documents to return. Default 10." }
114
+ },
115
+ required: ["query"]
116
+ }
117
+ },
118
+ {
119
+ name: "get_document",
120
+ description:
121
+ "Return one document in full, with its provenance envelope. The text is the canonical source — never a " +
122
+ "summary — so its sha256 can be checked against the envelope and against the anchored proof.",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: { slug: { type: "string", description: "Document slug, as returned by search_corpus or list_documents." } },
126
+ required: ["slug"]
127
+ }
128
+ },
129
+ {
130
+ name: "list_documents",
131
+ description:
132
+ "List the corpus: slugs, titles, genres, licences and provenance summaries, without full text. Use to " +
133
+ "orient before searching, or to enumerate what is available under a given licence.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ genre: { type: "string", description: "Optional: restrict to a genre." },
138
+ licence: { type: "string", description: "Optional: restrict to a licence id, e.g. CC0-1.0 or CC-BY-4.0." }
139
+ }
140
+ }
141
+ }
142
+ ];
143
+
144
+ const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
145
+
146
+ const excerpt = (body, query, span = 320) => {
147
+ const i = body.toLowerCase().indexOf(query.toLowerCase());
148
+ if (i === -1) return null;
149
+ const from = Math.max(0, i - span / 2);
150
+ return (from > 0 ? "…" : "") + body.slice(from, from + span).trim() + (from + span < body.length ? "…" : "");
151
+ };
152
+
153
+ const callTool = (name, args) => {
154
+ if (name === "search_corpus") {
155
+ const q = String(args?.query ?? "");
156
+ if (!q) throw new Error("query is required");
157
+ const limit = Number(args?.limit ?? 10);
158
+ const hits = corpus.documents
159
+ .filter((d) => !args?.genre || d.genre === args.genre)
160
+ .map((d) => ({ d, ex: excerpt(d.text, q) }))
161
+ .filter((h) => h.ex !== null)
162
+ .slice(0, limit)
163
+ .map((h) => ({ ...envelope(h.d), genre: h.d.genre, excerpt: h.ex }));
164
+ return text({ query: q, matches: hits.length, results: hits });
165
+ }
166
+
167
+ if (name === "get_document") {
168
+ const d = bySlug.get(String(args?.slug ?? ""));
169
+ if (!d) throw new Error(`no document with slug "${args?.slug}". Call list_documents to see what is available.`);
170
+ return text({ ...envelope(d), genre: d.genre, repo: d.repo, path: d.path, metadata_convention: d.metadata_convention, text: d.text });
171
+ }
172
+
173
+ if (name === "list_documents") {
174
+ const list = corpus.documents
175
+ .filter((d) => (!args?.genre || d.genre === args.genre) && (!args?.licence || d.licence.id === args.licence))
176
+ .map((d) => ({
177
+ slug: d.slug,
178
+ title: d.title,
179
+ genre: d.genre,
180
+ date: d.date,
181
+ licence: d.licence.id,
182
+ doi: d.provenance.doi,
183
+ opentimestamps: d.provenance.opentimestamps
184
+ }));
185
+ return text({ count: list.length, licences: corpus.licences, documents: list });
186
+ }
187
+
188
+ throw new Error(`unknown tool: ${name}`);
189
+ };
190
+
191
+ /* --------------------------------------------------------------- the loop --- */
192
+
193
+ const handlers = {
194
+ initialize: (params) => ({
195
+ protocolVersion: PROTOCOL_VERSIONS.includes(params?.protocolVersion) ? params.protocolVersion : PROTOCOL_VERSIONS[0],
196
+ capabilities: { tools: {} },
197
+ serverInfo: { name: "corpus.333.eco", version: "1.0.0" },
198
+ instructions:
199
+ "An open-licensed corpus served with verifiable provenance. Every document carries a sha256, and most " +
200
+ "carry a DOI and an OpenTimestamps proof anchored in Bitcoin, so you can check any passage you intend to " +
201
+ "cite rather than trusting this server. Documents under CC-BY carry attribute_to in their licence block; " +
202
+ "honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified."
203
+ }),
204
+ "tools/list": () => ({ tools: TOOLS }),
205
+ "tools/call": (params) => callTool(params?.name, params?.arguments)
206
+ };
207
+
208
+ createInterface({ input: process.stdin }).on("line", (line) => {
209
+ if (!line.trim()) return;
210
+ let msg;
211
+ try {
212
+ msg = JSON.parse(line);
213
+ } catch {
214
+ return failure(null, -32700, "parse error");
215
+ }
216
+ // Notifications carry no id and take no response — notifications/initialized
217
+ // above all, which a client sends and which must not be answered.
218
+ if (msg.id === undefined) return;
219
+
220
+ const handler = handlers[msg.method];
221
+ if (!handler) return failure(msg.id, -32601, `method not found: ${msg.method}`);
222
+ try {
223
+ result(msg.id, handler(msg.params));
224
+ } catch (e) {
225
+ failure(msg.id, -32603, e.message);
226
+ }
227
+ });