@jossuealcala/madre 1.0.0 → 1.0.2
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/ADDITIONAL-TERMS.md +93 -0
- package/CHANGELOG.en.md +1945 -0
- package/CHANGELOG.md +78 -1
- package/CONTRIBUTING.md +5 -1
- package/LICENSE +661 -202
- package/NOTICE +34 -3
- package/README.en.md +245 -0
- package/README.md +23 -13
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +82 -0
- package/bin/madre.mjs +2 -0
- package/docs/INTERNALS.md +8 -2
- package/docs/REFERENCE.md +17 -7
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +339 -0
- package/docs/en/SDK.md +306 -0
- package/docs/en/training/README.md +59 -0
- package/docs/madre-banner.png +0 -0
- package/docs/training/README.md +20 -8
- package/docs/training/train.sh +2 -1
- package/package.json +14 -5
- package/public/abduction.js +2 -0
- package/public/app.js +2066 -488
- package/public/attribution.js +71 -0
- package/public/brands.js +3 -1
- package/public/editor.js +2 -0
- package/public/es.js +1706 -1226
- package/public/i18n.js +25 -1
- package/public/index.html +13 -10
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +24 -2
- package/public/nostromo-engine.js +2 -0
- package/public/resay.js +6 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +539 -28
- package/public/salas-mac.html +30 -22
- package/public/salas-mac.js +1498 -144
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +1123 -20
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +164 -52
- package/public/tour.js +85 -12
- package/public/troubleshooting.js +20 -18
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/gemini.mjs +3 -1
- package/src/adapters/madre.mjs +3 -1
- package/src/adapters/opencode.mjs +7 -3
- package/src/adapters/process.mjs +2 -0
- package/src/asking.mjs +2 -0
- package/src/auth-probe.mjs +32 -7
- package/src/bridge/devices.mjs +2 -0
- package/src/bridge/handoff.mjs +2 -0
- package/src/bridge/host.mjs +2 -0
- package/src/bridge/network.mjs +2 -0
- package/src/bridge/policy.mjs +3 -1
- package/src/bridge/qr.mjs +2 -0
- package/src/bridge/server.mjs +2 -0
- package/src/capabilities.mjs +2 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +2 -0
- package/src/choices.mjs +2 -0
- package/src/cli-args.mjs +2 -0
- package/src/cold.mjs +2 -0
- package/src/commands.mjs +2 -0
- package/src/config.mjs +2 -0
- package/src/conversation-context.mjs +15 -1
- package/src/credentials.mjs +112 -3
- package/src/dataset.mjs +2 -0
- package/src/directives.mjs +8 -6
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +6 -4
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +33 -10
- package/src/eyecat-watch.mjs +2 -0
- package/src/eyecat.mjs +2 -0
- package/src/file-ops.mjs +2 -0
- package/src/files.mjs +32 -0
- package/src/github.mjs +2 -0
- package/src/heartbeat.mjs +57 -0
- package/src/i18n.mjs +15 -1
- package/src/image-studio.mjs +2 -0
- package/src/json-file.mjs +2 -0
- package/src/launch.mjs +2 -0
- package/src/lease.mjs +5 -1
- package/src/lobby-work.mjs +2 -0
- package/src/local-ai.mjs +50 -0
- package/src/looks.mjs +40 -5
- package/src/maturity.mjs +3 -1
- package/src/mcp/connect.mjs +345 -0
- package/src/mcp/github-server.mjs +2 -0
- package/src/mcp/image-server.mjs +2 -0
- package/src/mcp/memory-server.mjs +2 -0
- package/src/mcp/proxy.mjs +153 -0
- package/src/mcp/smtp-server.mjs +2 -0
- package/src/memory-dashboard.mjs +2 -0
- package/src/memory-tools.mjs +2 -0
- package/src/memory.mjs +2 -0
- package/src/models.mjs +83 -3
- package/src/modules/ahp.mjs +2 -0
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +13 -11
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +2 -0
- package/src/modules/git-pulse.mjs +2 -0
- package/src/modules/github.mjs +2 -0
- package/src/modules/helpers.mjs +2 -0
- package/src/modules/image-studio.mjs +3 -1
- package/src/modules/index.mjs +4 -1
- package/src/modules/intents-calibration.mjs +2 -0
- package/src/modules/intents-desk.mjs +4 -2
- package/src/modules/intents-observe.mjs +2 -0
- package/src/modules/intents-policy.mjs +4 -1
- package/src/modules/intents-try.mjs +2 -0
- package/src/modules/intents.mjs +8 -6
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +17 -3
- package/src/modules/playwright.mjs +25 -10
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +2 -0
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/ollama.mjs +2 -0
- package/src/outbound.mjs +24 -5
- package/src/override.mjs +2 -0
- package/src/ports.mjs +2 -0
- package/src/prices.mjs +2 -0
- package/src/privacy.mjs +2 -0
- package/src/quota-monitor.mjs +2 -0
- package/src/quota-sources.mjs +2 -0
- package/src/request-guard.mjs +2 -0
- package/src/review-ask.mjs +2 -0
- package/src/room/archivist.mjs +2 -0
- package/src/room/attachments.mjs +2 -0
- package/src/room/budget.mjs +2 -0
- package/src/room/context.mjs +2 -0
- package/src/room/control.mjs +7 -1
- package/src/room/copilot.mjs +76 -0
- package/src/room/economy.mjs +2 -0
- package/src/room/escalation.mjs +3 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +13 -4
- package/src/room/returns.mjs +82 -0
- package/src/room/runs.mjs +4 -0
- package/src/room/vectors.mjs +2 -0
- package/src/room.mjs +264 -45
- package/src/router.mjs +2 -0
- package/src/runs.mjs +2 -0
- package/src/runtime-detection.mjs +2 -0
- package/src/salas.mjs +2 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +485 -55
- package/src/setup.mjs +2 -0
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +26 -9
- package/src/updates.mjs +2 -0
- package/src/usage-sentinel.mjs +2 -0
- package/src/vault.mjs +2 -0
- package/src/verdict.mjs +3 -1
- package/src/whats-new.mjs +12 -10
- package/docs/madre-banner.svg +0 -25
package/src/lease.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { mkdir, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
4
|
import { join, relative, sep } from 'node:path';
|
|
3
5
|
import { declaredTypeFor } from './files.mjs';
|
|
@@ -119,7 +121,9 @@ export function leaseInstructions({ outDir, agentId, scopes = { write: true, ima
|
|
|
119
121
|
: canImage ? `You can generate images; save them${create ? ' where images live in this project, or in the scratch folder,' : ' into the lease directory'} with a descriptive file name.`
|
|
120
122
|
: couldImage ? 'Image generation is switched off for this request; if asked for an image, say so and do not attempt it.'
|
|
121
123
|
: 'You cannot generate images from this CLI; if asked for one, say so plainly instead of attempting it.',
|
|
122
|
-
|
|
124
|
+
// «Do not hand it to another agent» read as «do not plan»: with a lease, agents stopped proposing
|
|
125
|
+
// pulse plans altogether (2026-10-14). What is barred is the CLI's own subagents, not the room's.
|
|
126
|
+
'Do this work yourself rather than through your CLI\'s own subagents or spawned agents: a subagent writes outside this lease, so nothing it did would be checked, shown to the human or undoable — and the tools for it are blocked, which can leave you waiting instead of answering. A pulse plan that puts the room\'s other agents to work is different and still allowed.',
|
|
123
127
|
'List the files you created (or "none") before any plan block; nothing may follow a plan block.',
|
|
124
128
|
].filter(Boolean).join('\n');
|
|
125
129
|
}
|
package/src/lobby-work.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// What was worked on last on this Mac, for the lobby (2026-10-10): the newest conversations of
|
|
2
4
|
// every room the lobby knows, open or asleep, each with its last few lines, and the summary MADRE
|
|
3
5
|
// writes of them with its local model. Everything here READS: a room's list of conversations
|
package/src/local-ai.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
3
|
+
// Which projects use the local AI (Ollama), decided from Home (2026-10-13).
|
|
4
|
+
//
|
|
5
|
+
// Ollama is one per computer and its switches live in ~/.pulse/config.json, which every project
|
|
6
|
+
// shares: `modules.ollama` says whether the local AI is on at all and what it does (search by
|
|
7
|
+
// meaning, summing up the memory, @madre). This adds the one thing that file could not say —
|
|
8
|
+
// which projects — without touching any of that:
|
|
9
|
+
//
|
|
10
|
+
// localAi.mode 'auto' every project uses it, new ones too. Home decides; projects follow.
|
|
11
|
+
// 'pick' only the projects switched on. New ones start without it.
|
|
12
|
+
// 'manual' each project decides in its own Settings. New ones start with it.
|
|
13
|
+
// localAi.rooms { <room id>: true | false } what was switched, from Home or from the project.
|
|
14
|
+
//
|
|
15
|
+
// With nothing written the mode is 'auto', which is what MADRE did before this existed. The
|
|
16
|
+
// master switch (`modules.ollama.enabled`) still wins: off there is off everywhere. Home's own
|
|
17
|
+
// room, the Coordinator's, is not a project and always follows the master switch.
|
|
18
|
+
|
|
19
|
+
export const LOCAL_MODES = ['auto', 'pick', 'manual'];
|
|
20
|
+
|
|
21
|
+
export function localAiConfig(config = {}) {
|
|
22
|
+
const raw = config.localAi ?? {};
|
|
23
|
+
const mode = LOCAL_MODES.includes(raw.mode) ? raw.mode : 'auto';
|
|
24
|
+
const rooms = {};
|
|
25
|
+
for (const [id, on] of Object.entries(raw.rooms ?? {})) if (typeof on === 'boolean') rooms[id] = on;
|
|
26
|
+
return { mode, rooms };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What one project gets. `on`: whether it uses the local AI now; `chosen`: what this project's
|
|
30
|
+
// switch says, before the master switch; `editable`: whether its switch can be moved (not in auto).
|
|
31
|
+
export function localAiFor(config = {}, roomId = null, { home = false } = {}) {
|
|
32
|
+
const master = (config.modules?.ollama?.enabled ?? true) !== false;
|
|
33
|
+
const { mode, rooms } = localAiConfig(config);
|
|
34
|
+
if (home || !roomId) return { mode, master, chosen: true, on: master, editable: false, home: Boolean(home) };
|
|
35
|
+
const chosen = mode === 'auto' ? true : mode === 'pick' ? rooms[roomId] === true : rooms[roomId] ?? true;
|
|
36
|
+
return { mode, master, chosen, on: master && chosen, editable: mode !== 'auto' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The patch for config.json: the whole `localAi` block, because updateConfig merges one level
|
|
40
|
+
// down and a partial `rooms` would drop the projects it does not name.
|
|
41
|
+
export function localAiPatch(config = {}, { mode, rooms } = {}) {
|
|
42
|
+
const now = localAiConfig(config);
|
|
43
|
+
const next = { mode: LOCAL_MODES.includes(mode) ? mode : now.mode, rooms: { ...now.rooms } };
|
|
44
|
+
for (const [id, on] of Object.entries(rooms ?? {})) {
|
|
45
|
+
if (typeof id !== 'string' || !id || id.length > 200) continue;
|
|
46
|
+
if (typeof on === 'boolean') next.rooms[id] = on;
|
|
47
|
+
else if (on === null) delete next.rooms[id];
|
|
48
|
+
}
|
|
49
|
+
return { localAi: next };
|
|
50
|
+
}
|
package/src/looks.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// How the person wants MADRE to look, once for every page (2026-10-12). Each project is its own
|
|
2
4
|
// origin — another port — and Home is one more, so what a browser keeps for one is invisible to
|
|
3
5
|
// the others: the dark theme chosen in Home left every project light. The choices live here, in
|
|
@@ -26,16 +28,48 @@ export const LOOK_KEYS = {
|
|
|
26
28
|
};
|
|
27
29
|
const looksFile = (stateRoot) => join(stateRoot, 'looks.json');
|
|
28
30
|
|
|
31
|
+
// A project may keep a wallpaper of its own (2026-10-13): the person said «only in this project».
|
|
32
|
+
// It lives beside the shared looks, under `rooms`, by the room's id; every other look stays shared.
|
|
33
|
+
const ROOM_ID = /^[\w .-]{1,120}$/;
|
|
34
|
+
const readFileLooks = (stateRoot) => readFile(looksFile(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
|
|
35
|
+
function roomsOf(raw) {
|
|
36
|
+
const rooms = {};
|
|
37
|
+
for (const [id, own] of Object.entries(raw?.rooms ?? {})) {
|
|
38
|
+
if (ROOM_ID.test(id) && typeof own?.wallpaper === 'string' && CHOICE.test(own.wallpaper)) rooms[id] = { wallpaper: own.wallpaper };
|
|
39
|
+
}
|
|
40
|
+
return rooms;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
// What is kept, read key by key: anything else in the file, or a value of the wrong shape, is not
|
|
30
|
-
// a look. `null` for a key means the first value — the room as it always was.
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
// a look. `null` for a key means the first value — the room as it always was. With `room`, that
|
|
45
|
+
// project's own wallpaper comes back too, as `own`.
|
|
46
|
+
export async function readLooks(stateRoot, { room = null } = {}) {
|
|
47
|
+
const raw = await readFileLooks(stateRoot);
|
|
33
48
|
const looks = {};
|
|
34
49
|
for (const [key, shape] of Object.entries(LOOK_KEYS)) {
|
|
35
50
|
const value = raw?.looks?.[key];
|
|
36
51
|
looks[key] = typeof value === 'string' && shape.test(value) ? value : null;
|
|
37
52
|
}
|
|
38
|
-
|
|
53
|
+
const answer = { looks, saved: Boolean(raw && typeof raw.looks === 'object') };
|
|
54
|
+
if (room) answer.own = roomsOf(raw)[room] ?? null;
|
|
55
|
+
return answer;
|
|
56
|
+
}
|
|
57
|
+
// Every project's own wallpaper, for Home's cards.
|
|
58
|
+
export async function roomLooks(stateRoot) {
|
|
59
|
+
return roomsOf(await readFileLooks(stateRoot));
|
|
60
|
+
}
|
|
61
|
+
// A project's own wallpaper set, or taken away (`null`): it follows the shared one again.
|
|
62
|
+
export function changeRoomLook(stateRoot, room, wallpaper) {
|
|
63
|
+
return oneAtATime(looksFile(stateRoot), async () => {
|
|
64
|
+
if (typeof room !== 'string' || !ROOM_ID.test(room)) return { error: 'room' };
|
|
65
|
+
if (wallpaper !== null && (typeof wallpaper !== 'string' || !CHOICE.test(wallpaper))) return { error: 'value' };
|
|
66
|
+
const raw = await readFileLooks(stateRoot);
|
|
67
|
+
const { looks } = await readLooks(stateRoot);
|
|
68
|
+
const rooms = roomsOf(raw);
|
|
69
|
+
if (wallpaper === null) delete rooms[room]; else rooms[room] = { wallpaper };
|
|
70
|
+
await writeWhole(looksFile(stateRoot), `${JSON.stringify({ looks, ...(Object.keys(rooms).length ? { rooms } : {}), at: new Date().toISOString() }, null, 2)}\n`);
|
|
71
|
+
return { own: rooms[room] ?? null };
|
|
72
|
+
});
|
|
39
73
|
}
|
|
40
74
|
|
|
41
75
|
// A change from a page: the keys it names, set or cleared. A key it does not know, or a value of
|
|
@@ -49,7 +83,8 @@ export function changeLooks(stateRoot, patch, now = new Date()) {
|
|
|
49
83
|
}
|
|
50
84
|
const { looks } = await readLooks(stateRoot);
|
|
51
85
|
Object.assign(looks, patch);
|
|
52
|
-
|
|
86
|
+
const rooms = roomsOf(await readFileLooks(stateRoot));
|
|
87
|
+
await writeWhole(looksFile(stateRoot), `${JSON.stringify({ looks, ...(Object.keys(rooms).length ? { rooms } : {}), at: new Date(now).toISOString() }, null, 2)}\n`);
|
|
53
88
|
return { looks, saved: true };
|
|
54
89
|
});
|
|
55
90
|
}
|
package/src/maturity.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// How grown a room is, and how far from being worth training on.
|
|
2
4
|
//
|
|
3
5
|
// Pure functions. The room hands in what it has; nothing here reads a file or calls a model.
|
|
@@ -45,7 +47,7 @@ export function maturity({ readiness = null, notes = [], links = [], stats = nul
|
|
|
45
47
|
next: t('Memories nobody has needed may be noise, or may simply not have come up yet. Ask the project about older decisions and see which ones answer.'),
|
|
46
48
|
},
|
|
47
49
|
{
|
|
48
|
-
id: 'weave', label: t('HOW
|
|
50
|
+
id: 'weave', label: t('HOW CONNECTED IT IS'),
|
|
49
51
|
value: round(share(connected, standing.length)),
|
|
50
52
|
detail: t('{connected} of {total} memories share a subject with another', { connected, total: standing.length }),
|
|
51
53
|
next: t('An archive of unrelated notes is a list. Depth comes from returning to the same subjects.'),
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
3
|
+
// CONNECT MCP: the MCP servers a person adds to one project, for its agents (2026-10-14).
|
|
4
|
+
//
|
|
5
|
+
// Pure-ish helpers shared by the module (src/modules/mcp-connect.mjs), which owns the settings and
|
|
6
|
+
// hands servers to turns, and the proxy (src/mcp/proxy.mjs), which is what each CLI actually
|
|
7
|
+
// starts. The rules, so nothing added here can break the room:
|
|
8
|
+
//
|
|
9
|
+
// · A server is handed to a turn only after MADRE has started it and read its tool list. One
|
|
10
|
+
// that fails its check stays off and says why; a turn never carries a server nobody saw work.
|
|
11
|
+
// · Every CLI starts the same proxy, with the project, the server's id and the tools it may
|
|
12
|
+
// use: no secret is ever in an argv, where `ps` shows it (the env of a CLI's MCP entry is).
|
|
13
|
+
// The proxy reads its secrets from the vault itself and refuses any tool not allowed.
|
|
14
|
+
// · Secrets — environment values marked secret, a token, OAuth tokens — live in the vault
|
|
15
|
+
// (src/vault.mjs) under the module `mcp`. The settings file holds names, never values.
|
|
16
|
+
// · A tool that only reads (the server says `readOnlyHint`) works from #1. A tool that acts is
|
|
17
|
+
// handed only from the mode the person chose for that server, #4 FULL ACCESS unless they
|
|
18
|
+
// lower it, which is the rule every connector of MADRE already follows (docs/CONECTORES.md).
|
|
19
|
+
//
|
|
20
|
+
// The settings live beside the project's room, in `<state>/rooms/<room>/mcp.json`: they are of
|
|
21
|
+
// this project only, as the person asked, and they never land in the project's own folder.
|
|
22
|
+
|
|
23
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
24
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { forgetSecret, readSecret, writeSecret } from '../vault.mjs';
|
|
28
|
+
import { projectRoomId } from '../salas.mjs';
|
|
29
|
+
import { splitCommand } from '../runs.mjs';
|
|
30
|
+
|
|
31
|
+
export const MCP_VAULT = 'mcp';
|
|
32
|
+
export const SERVER_ID = /^[a-z][a-z0-9-]{0,30}$/;
|
|
33
|
+
// Names MADRE's own servers use, so a person's server can never stand in for one of them.
|
|
34
|
+
const RESERVED = /^(pulse|madre|mcp)(-|$)/;
|
|
35
|
+
export const ACTION_MODES = [1, 2, 3, 4];
|
|
36
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
37
|
+
const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
|
|
38
|
+
|
|
39
|
+
export const mcpFile = (stateRoot, projectRoot) => join(stateRoot, 'rooms', projectRoomId(projectRoot), 'mcp.json');
|
|
40
|
+
|
|
41
|
+
export async function readMcp(stateRoot, projectRoot) {
|
|
42
|
+
try {
|
|
43
|
+
const stored = JSON.parse(await readFile(mcpFile(stateRoot, projectRoot), 'utf8'));
|
|
44
|
+
return { servers: Array.isArray(stored?.servers) ? stored.servers.filter((one) => one && SERVER_ID.test(one.id)) : [] };
|
|
45
|
+
} catch { return { servers: [] }; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function writeMcp(stateRoot, projectRoot, settings) {
|
|
49
|
+
const file = mcpFile(stateRoot, projectRoot);
|
|
50
|
+
await mkdir(join(file, '..'), { recursive: true });
|
|
51
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
52
|
+
await writeFile(temp, `${JSON.stringify({ servers: settings.servers ?? [] }, null, 2)}\n`);
|
|
53
|
+
await rename(temp, file);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The vault names of a server's secrets: one per environment value, one per header, one for OAuth.
|
|
57
|
+
export const secretName = (id, kind, key = '') => `${id}_${kind}${key ? `_${key}` : ''}`.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 64);
|
|
58
|
+
|
|
59
|
+
// What a person typed, made into a server or refused with the reason. Values marked secret are
|
|
60
|
+
// returned apart (`secrets`), for the vault; what is kept here has names only.
|
|
61
|
+
export function cleanServer(input = {}, { existing = [] } = {}) {
|
|
62
|
+
const id = String(input.id ?? '').trim().toLowerCase();
|
|
63
|
+
if (!SERVER_ID.test(id)) return { error: 'The name is a short word: lowercase letters, digits and dashes, starting with a letter.' };
|
|
64
|
+
if (RESERVED.test(id)) return { error: 'That name belongs to MADRE. Choose another.' };
|
|
65
|
+
if (existing.some((one) => one.id === id) && input.replace !== true) return { error: 'There is already a server with that name in this project.' };
|
|
66
|
+
const transport = input.transport === 'http' ? 'http' : 'stdio';
|
|
67
|
+
const server = {
|
|
68
|
+
id,
|
|
69
|
+
label: String(input.label ?? '').trim().slice(0, 60) || id,
|
|
70
|
+
transport,
|
|
71
|
+
enabled: input.enabled !== false,
|
|
72
|
+
actions: ACTION_MODES.includes(Number(input.actions)) ? Number(input.actions) : 4,
|
|
73
|
+
agents: Array.isArray(input.agents) && input.agents.length ? input.agents.filter((one) => /^[a-z0-9_-]+$/.test(one)) : null,
|
|
74
|
+
env: [],
|
|
75
|
+
headers: [],
|
|
76
|
+
auth: 'none',
|
|
77
|
+
tools: Array.isArray(input.tools) ? input.tools : [],
|
|
78
|
+
checkedAt: input.checkedAt ?? null,
|
|
79
|
+
problem: input.problem ?? null,
|
|
80
|
+
};
|
|
81
|
+
const secrets = [];
|
|
82
|
+
if (transport === 'stdio') {
|
|
83
|
+
const line = String(input.command ?? '').trim();
|
|
84
|
+
const split = splitCommand(line);
|
|
85
|
+
if (!line) return { error: 'Write the command that starts the server.' };
|
|
86
|
+
// The refusal is said the way a run request says it (src/runs.mjs), in the room's language.
|
|
87
|
+
if (!split.argv) return { error: 'The command {why}; write it as one program and its arguments.', errorArgs: { why: split.why, whyArgs: split.whyArgs ?? {} } };
|
|
88
|
+
[server.command, ...server.args] = split.argv;
|
|
89
|
+
// Words that open with «~/» are the home folder, put in only when the server starts.
|
|
90
|
+
if (split.home?.length) server.home = split.home;
|
|
91
|
+
server.line = line;
|
|
92
|
+
} else {
|
|
93
|
+
let url;
|
|
94
|
+
try { url = new URL(String(input.url ?? '').trim()); } catch { return { error: 'Write the full address of the server; a remote one starts with https.' }; }
|
|
95
|
+
const local = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
|
|
96
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return { error: 'A remote server has to be https://; plain http:// only on this computer.' };
|
|
97
|
+
if (url.username || url.password) return { error: 'Put the credentials in the token field, not in the address.' };
|
|
98
|
+
server.url = url.toString();
|
|
99
|
+
server.auth = ['token', 'oauth'].includes(input.auth) ? input.auth : 'none';
|
|
100
|
+
}
|
|
101
|
+
for (const row of Array.isArray(input.env) ? input.env : []) {
|
|
102
|
+
const name = String(row?.name ?? '').trim();
|
|
103
|
+
if (!name) continue;
|
|
104
|
+
if (!ENV_NAME.test(name)) return { error: '{name} is not a variable name.', errorArgs: { name } };
|
|
105
|
+
if (row.secret) {
|
|
106
|
+
server.env.push({ name, secret: true });
|
|
107
|
+
if (typeof row.value === 'string' && row.value.trim()) secrets.push({ name: secretName(id, 'env', name), value: row.value.trim() });
|
|
108
|
+
} else server.env.push({ name, value: String(row.value ?? '') });
|
|
109
|
+
}
|
|
110
|
+
for (const row of Array.isArray(input.headers) ? input.headers : []) {
|
|
111
|
+
const name = String(row?.name ?? '').trim();
|
|
112
|
+
if (!name) continue;
|
|
113
|
+
if (!HEADER_NAME.test(name)) return { error: '{name} is not a header name.', errorArgs: { name } };
|
|
114
|
+
// «Bearer abc» is a prefix and a secret; the vault holds the part with no spaces.
|
|
115
|
+
const value = String(row.value ?? '').trim();
|
|
116
|
+
const cut = value.lastIndexOf(' ');
|
|
117
|
+
const prefix = row.secret && cut > 0 ? value.slice(0, cut + 1) : row.prefix ?? '';
|
|
118
|
+
if (row.secret) {
|
|
119
|
+
server.headers.push({ name, prefix, secret: true });
|
|
120
|
+
const secret = cut > 0 ? value.slice(cut + 1) : value;
|
|
121
|
+
if (secret) secrets.push({ name: secretName(id, 'hdr', name), value: secret });
|
|
122
|
+
} else server.headers.push({ name, value });
|
|
123
|
+
}
|
|
124
|
+
if (transport === 'http' && server.auth === 'token') {
|
|
125
|
+
server.headers = server.headers.filter((one) => one.name.toLowerCase() !== 'authorization');
|
|
126
|
+
if (typeof input.token === 'string' && input.token.trim()) secrets.push({ name: secretName(id, 'token'), value: input.token.trim().replace(/^Bearer\s+/i, '') });
|
|
127
|
+
}
|
|
128
|
+
return { server, secrets };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function keepSecrets(stateRoot, secrets) {
|
|
132
|
+
for (const one of secrets) {
|
|
133
|
+
const kept = await writeSecret(stateRoot, MCP_VAULT, one.name, one.value);
|
|
134
|
+
if (!kept.ok) return kept;
|
|
135
|
+
}
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function forgetServerSecrets(stateRoot, server) {
|
|
140
|
+
const names = [
|
|
141
|
+
...(server.env ?? []).filter((one) => one.secret).map((one) => secretName(server.id, 'env', one.name)),
|
|
142
|
+
...(server.headers ?? []).filter((one) => one.secret).map((one) => secretName(server.id, 'hdr', one.name)),
|
|
143
|
+
secretName(server.id, 'token'), secretName(server.id, 'oauth'),
|
|
144
|
+
];
|
|
145
|
+
for (const name of names) await forgetSecret(stateRoot, MCP_VAULT, name);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The environment the server starts with: the literal values and the secrets, read now.
|
|
149
|
+
export async function resolveEnv(stateRoot, server) {
|
|
150
|
+
const env = {};
|
|
151
|
+
for (const one of server.env ?? []) {
|
|
152
|
+
env[one.name] = one.secret ? (await readSecret(stateRoot, MCP_VAULT, secretName(server.id, 'env', one.name))) ?? '' : String(one.value ?? '');
|
|
153
|
+
}
|
|
154
|
+
return env;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function resolveHeaders(stateRoot, server) {
|
|
158
|
+
const headers = {};
|
|
159
|
+
for (const one of server.headers ?? []) {
|
|
160
|
+
headers[one.name] = one.secret ? `${one.prefix ?? ''}${(await readSecret(stateRoot, MCP_VAULT, secretName(server.id, 'hdr', one.name))) ?? ''}` : String(one.value ?? '');
|
|
161
|
+
}
|
|
162
|
+
if (server.auth === 'token') {
|
|
163
|
+
const token = await readSecret(stateRoot, MCP_VAULT, secretName(server.id, 'token'));
|
|
164
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
165
|
+
} else if (server.auth === 'oauth') {
|
|
166
|
+
const grant = await readOAuth(stateRoot, server.id);
|
|
167
|
+
if (grant?.access_token) headers.Authorization = `Bearer ${grant.access_token}`;
|
|
168
|
+
}
|
|
169
|
+
return headers;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- OAuth, the way remote MCP servers ask to be signed in to ----------
|
|
173
|
+
//
|
|
174
|
+
// The server says where its authorization server is (RFC 9728), that server says how to register
|
|
175
|
+
// a client and where to send the person (RFC 8414 / 7591), and the code comes back to this room
|
|
176
|
+
// with PKCE (RFC 7636) and the server named as the resource (RFC 8707). MADRE registers itself as
|
|
177
|
+
// a public client — no secret, because a secret kept on a laptop is not one — and keeps the
|
|
178
|
+
// tokens in the vault. The proxy refreshes them when the server says they ran out.
|
|
179
|
+
|
|
180
|
+
const b64url = (buffer) => Buffer.from(buffer).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
181
|
+
export function pkce() {
|
|
182
|
+
const verifier = b64url(randomBytes(32));
|
|
183
|
+
return { verifier, challenge: b64url(createHash('sha256').update(verifier).digest()) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function getJson(fetchImpl, url) {
|
|
187
|
+
const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10000) });
|
|
188
|
+
if (!response.ok) return null;
|
|
189
|
+
return response.json().catch(() => null);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function discoverOAuth(serverUrl, { fetchImpl = globalThis.fetch } = {}) {
|
|
193
|
+
const url = new URL(serverUrl);
|
|
194
|
+
// The server's own pointer first: a 401 names its resource metadata in WWW-Authenticate.
|
|
195
|
+
let resourceMeta = null;
|
|
196
|
+
const probe = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'madre', version: '1' } } }), signal: AbortSignal.timeout(10000) }).catch(() => null);
|
|
197
|
+
const pointer = probe?.headers?.get?.('www-authenticate')?.match(/resource_metadata="([^"]+)"/)?.[1];
|
|
198
|
+
const candidates = [
|
|
199
|
+
pointer,
|
|
200
|
+
`${url.origin}/.well-known/oauth-protected-resource${url.pathname === '/' ? '' : url.pathname}`,
|
|
201
|
+
`${url.origin}/.well-known/oauth-protected-resource`,
|
|
202
|
+
].filter(Boolean);
|
|
203
|
+
for (const one of candidates) { resourceMeta = await getJson(fetchImpl, one).catch(() => null); if (resourceMeta) break; }
|
|
204
|
+
const issuer = resourceMeta?.authorization_servers?.[0] ?? url.origin;
|
|
205
|
+
const issuerUrl = new URL(issuer);
|
|
206
|
+
const suffix = issuerUrl.pathname === '/' ? '' : issuerUrl.pathname;
|
|
207
|
+
let authMeta = null;
|
|
208
|
+
for (const one of [`${issuerUrl.origin}/.well-known/oauth-authorization-server${suffix}`, `${issuerUrl.origin}/.well-known/openid-configuration${suffix}`]) {
|
|
209
|
+
authMeta = await getJson(fetchImpl, one).catch(() => null);
|
|
210
|
+
if (authMeta?.authorization_endpoint && authMeta?.token_endpoint) break;
|
|
211
|
+
authMeta = null;
|
|
212
|
+
}
|
|
213
|
+
if (!authMeta) return { ok: false, problem: 'The server does not publish how to sign in to it (no OAuth metadata). Use a token instead.' };
|
|
214
|
+
if (!authMeta.registration_endpoint) return { ok: false, problem: 'The server only accepts clients it already knows; it does not let MADRE register. Use a token instead.' };
|
|
215
|
+
return { ok: true, resource: resourceMeta?.resource ?? url.toString(), scopes: resourceMeta?.scopes_supported ?? authMeta.scopes_supported ?? [], authorization: authMeta };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function registerClient(authorization, redirectUri, { fetchImpl = globalThis.fetch } = {}) {
|
|
219
|
+
const response = await fetchImpl(authorization.registration_endpoint, {
|
|
220
|
+
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
221
|
+
body: JSON.stringify({ client_name: 'MADRE', redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }),
|
|
222
|
+
signal: AbortSignal.timeout(10000),
|
|
223
|
+
});
|
|
224
|
+
const said = await response.json().catch(() => ({}));
|
|
225
|
+
if (!response.ok || !said.client_id) return { ok: false, problem: said.error_description ?? said.error ?? `the server refused the registration (HTTP ${response.status})` };
|
|
226
|
+
return { ok: true, clientId: said.client_id };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function authorizeUrl({ authorization, clientId, redirectUri, challenge, state, resource, scopes = [] }) {
|
|
230
|
+
const url = new URL(authorization.authorization_endpoint);
|
|
231
|
+
url.searchParams.set('response_type', 'code');
|
|
232
|
+
url.searchParams.set('client_id', clientId);
|
|
233
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
234
|
+
url.searchParams.set('code_challenge', challenge);
|
|
235
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
236
|
+
url.searchParams.set('state', state);
|
|
237
|
+
url.searchParams.set('resource', resource);
|
|
238
|
+
if (scopes.length) url.searchParams.set('scope', scopes.join(' '));
|
|
239
|
+
return url.toString();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function tokenRequest(tokenEndpoint, form, fetchImpl) {
|
|
243
|
+
const response = await fetchImpl(tokenEndpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams(form).toString(), signal: AbortSignal.timeout(15000) });
|
|
244
|
+
const said = await response.json().catch(() => ({}));
|
|
245
|
+
if (!response.ok || !said.access_token) return { ok: false, problem: said.error_description ?? said.error ?? `HTTP ${response.status}` };
|
|
246
|
+
return { ok: true, tokens: said };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function exchangeCode({ authorization, clientId, redirectUri, code, verifier, resource }, { fetchImpl = globalThis.fetch } = {}) {
|
|
250
|
+
return tokenRequest(authorization.token_endpoint, { grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier, resource }, fetchImpl);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function refreshGrant(stateRoot, id, { fetchImpl = globalThis.fetch } = {}) {
|
|
254
|
+
const grant = await readOAuth(stateRoot, id);
|
|
255
|
+
if (!grant?.refresh_token || !grant.token_endpoint) return { ok: false, problem: 'no refresh token' };
|
|
256
|
+
const renewed = await tokenRequest(grant.token_endpoint, { grant_type: 'refresh_token', refresh_token: grant.refresh_token, client_id: grant.client_id, resource: grant.resource }, fetchImpl);
|
|
257
|
+
if (!renewed.ok) return renewed;
|
|
258
|
+
await keepOAuth(stateRoot, id, { ...grant, ...grantFrom(renewed.tokens), refresh_token: renewed.tokens.refresh_token ?? grant.refresh_token });
|
|
259
|
+
return { ok: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const grantFrom = (tokens) => ({
|
|
263
|
+
access_token: tokens.access_token,
|
|
264
|
+
refresh_token: tokens.refresh_token ?? null,
|
|
265
|
+
expires_at: Number.isFinite(Number(tokens.expires_in)) ? Date.now() + Number(tokens.expires_in) * 1000 : null,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// The vault takes one word per secret: the grant is kept as base64url JSON.
|
|
269
|
+
export async function keepOAuth(stateRoot, id, grant) {
|
|
270
|
+
return writeSecret(stateRoot, MCP_VAULT, secretName(id, 'oauth'), b64url(Buffer.from(JSON.stringify(grant))));
|
|
271
|
+
}
|
|
272
|
+
export async function readOAuth(stateRoot, id) {
|
|
273
|
+
const kept = await readSecret(stateRoot, MCP_VAULT, secretName(id, 'oauth'));
|
|
274
|
+
if (!kept) return null;
|
|
275
|
+
try { return JSON.parse(Buffer.from(kept.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch { return null; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------- servers the person already set up in their other apps ----------
|
|
279
|
+
//
|
|
280
|
+
// Read only when they press IMPORT, only the MCP entries, and shown by name: a value never leaves
|
|
281
|
+
// this computer and is put in the vault the moment it is imported.
|
|
282
|
+
|
|
283
|
+
function fromJsonServers(source, entries) {
|
|
284
|
+
return Object.entries(entries ?? {}).map(([name, one]) => {
|
|
285
|
+
if (!one || typeof one !== 'object') return null;
|
|
286
|
+
const url = one.url ?? one.httpUrl ?? one.serverUrl ?? null;
|
|
287
|
+
const command = Array.isArray(one.command) ? one.command : one.command ? [one.command, ...(one.args ?? [])] : null;
|
|
288
|
+
return { source, name, url, command, env: one.env ?? one.environment ?? {}, headers: one.headers ?? {}, disabled: one.enabled === false || one.disabled === true };
|
|
289
|
+
}).filter(Boolean);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The [mcp_servers.<name>] tables of Codex's config.toml: command, args, url and an env table.
|
|
293
|
+
// Only those shapes, read line by line; anything else in the file is not looked at.
|
|
294
|
+
export function codexServers(text) {
|
|
295
|
+
const found = new Map();
|
|
296
|
+
let current = null;
|
|
297
|
+
let inEnv = false;
|
|
298
|
+
const value = (raw) => {
|
|
299
|
+
const trimmed = raw.trim();
|
|
300
|
+
if (/^".*"$/.test(trimmed)) { try { return JSON.parse(trimmed); } catch { return trimmed.slice(1, -1); } }
|
|
301
|
+
if (/^'.*'$/.test(trimmed)) return trimmed.slice(1, -1);
|
|
302
|
+
if (/^\[.*\]$/.test(trimmed)) { try { return JSON.parse(trimmed.replace(/'/g, '"')); } catch { return []; } }
|
|
303
|
+
if (trimmed === 'true' || trimmed === 'false') return trimmed === 'true';
|
|
304
|
+
return trimmed;
|
|
305
|
+
};
|
|
306
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
307
|
+
const table = line.match(/^\s*\[mcp_servers\.([A-Za-z0-9_-]+)(\.env)?\]\s*$/);
|
|
308
|
+
if (table) { current = table[1]; inEnv = Boolean(table[2]); if (!found.has(current)) found.set(current, { env: {} }); continue; }
|
|
309
|
+
if (/^\s*\[/.test(line)) { current = null; continue; }
|
|
310
|
+
if (!current) continue;
|
|
311
|
+
const pair = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.+?)\s*$/);
|
|
312
|
+
if (!pair) continue;
|
|
313
|
+
if (inEnv) found.get(current).env[pair[1]] = String(value(pair[2]));
|
|
314
|
+
else found.get(current)[pair[1]] = value(pair[2]);
|
|
315
|
+
}
|
|
316
|
+
return [...found.entries()].map(([name, one]) => ({
|
|
317
|
+
source: 'Codex', name, url: one.url ?? null,
|
|
318
|
+
command: one.command ? [one.command, ...(Array.isArray(one.args) ? one.args : [])] : null,
|
|
319
|
+
env: one.env, headers: {}, disabled: one.enabled === false,
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function importable({ projectRoot, home = homedir() } = {}) {
|
|
324
|
+
const read = (path) => readFile(path, 'utf8').catch(() => null);
|
|
325
|
+
const json = async (path) => { const text = await read(path); try { return text ? JSON.parse(text) : null; } catch { return null; } };
|
|
326
|
+
const found = [];
|
|
327
|
+
const project = await json(join(projectRoot, '.mcp.json'));
|
|
328
|
+
found.push(...fromJsonServers('.mcp.json (this project)', project?.mcpServers));
|
|
329
|
+
const opencode = await json(join(projectRoot, 'opencode.json'));
|
|
330
|
+
found.push(...fromJsonServers('opencode.json (this project)', opencode?.mcp));
|
|
331
|
+
const claude = await json(join(home, '.claude.json'));
|
|
332
|
+
found.push(...fromJsonServers('Claude Code', claude?.mcpServers));
|
|
333
|
+
found.push(...fromJsonServers('Claude Code (this project)', claude?.projects?.[projectRoot]?.mcpServers));
|
|
334
|
+
found.push(...fromJsonServers('~/.mcp.json', (await json(join(home, '.mcp.json')))?.mcpServers));
|
|
335
|
+
found.push(...fromJsonServers('Gemini CLI', (await json(join(home, '.gemini', 'settings.json')))?.mcpServers));
|
|
336
|
+
found.push(...fromJsonServers('Cursor', (await json(join(home, '.cursor', 'mcp.json')))?.mcpServers));
|
|
337
|
+
const toml = await read(join(home, '.codex', 'config.toml'));
|
|
338
|
+
if (toml) found.push(...codexServers(toml));
|
|
339
|
+
// A name that is not a usable id gets one; values are counted, never shown.
|
|
340
|
+
return found.map((one) => ({
|
|
341
|
+
...one,
|
|
342
|
+
id: String(one.name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 31) || 'server',
|
|
343
|
+
transport: one.url ? 'http' : 'stdio',
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
2
4
|
// MADRE GITHUB: a small MCP server (stdio, JSON-RPC 2.0) that lets an agent read the GitHub side
|
|
3
5
|
// of this project — pull requests, issues, checks — and, in #4 AIRLOCK only, open a pull request,
|
|
4
6
|
// open an issue or leave a comment. There is no MADRE in the middle: this process asks gh, or
|
package/src/mcp/image-server.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
2
4
|
// MADRE Image Studio: a tiny MCP server (stdio, JSON-RPC 2.0) that exposes one
|
|
3
5
|
// tool, generate_image, backed by the Gemini API image models and the user's
|
|
4
6
|
// own Gemini key. MADRE attaches it to the isolated homes of the CLIs that
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
2
4
|
// MADRE memory: a tiny MCP server (stdio, JSON-RPC 2.0) that lets the agent in
|
|
3
5
|
// a turn consult the room's memory itself: everything said outside GHOST,
|
|
4
6
|
// the distilled notes, exact stretches of the ledger, and AHP+'s project
|