Skip to content

Commit 129c869

Browse files
JoshuaJewellclaude
andauthored
Modularize MCP bridge, add functional tests, fix version drift (#27)
## Summary - **Modularize MCP bridge**: Split the monolithic 1126-line `main.js` into 7 focused modules (`security.js`, `api-clients.js`, `tools.js`, `logger.js`, `version.js`, `offline-menu.js`, `generate-offline-menu.js`), reducing `main.js` to ~230 lines of transport + dispatch logic - **Add 44 functional tests**: New `tests/security_test.js` exercising actual code paths — injection detection (including unicode bypass prevention via zero-width chars, Cyrillic confusables, fullwidth chars), rate limiting, input validation, tool/cartridge name validation, and error sanitization - **Fix version drift**: Sync version `0.3.1` across `package.json`, `mcp-bridge/package.json`, `Justfile`, and new `lib/version.js` single source of truth - **Fix documentation**: Correct internal dev machine paths in `EXPLAINME.adoc`, update cartridge count (92→96) in `TOPOLOGY.md`, update file map to reflect actual repo structure - **Improve security**: Add unicode normalization to prompt injection detection (strips zero-width chars, normalizes Cyrillic/fullwidth confusables, collapses whitespace) to prevent bypass techniques - **Add structured logging**: JSON-structured log output to stderr with configurable log levels via `BOJ_LOG_LEVEL` - **Wire `npm test`**: Now runs real tests instead of `echo` no-op; adds ESLint config for the MCP bridge ## Test plan - [x] All 44 security module tests pass (`npm test`) - [x] Injection detection correctly classifies none/low/medium/high/critical - [x] Unicode bypass prevention verified (zero-width, Cyrillic, fullwidth) - [x] Input size limits, field validation, tool name validation all tested - [x] Error sanitization strips paths, stack traces, env vars - [ ] Manual: verify `node mcp-bridge/main.js` starts and responds to MCP initialize - [ ] Manual: verify existing Deno smoke/E2E tests still pass https://claude.ai/code/session_01FJczaDsKEoetZGKrZ1fdtc Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a58846 commit 129c869

15 files changed

Lines changed: 1452 additions & 1003 deletions

EXPLAINME.adoc

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ This is the Model-Controller-Processor pattern: cartridges are pluggable service
1616

1717
=== Claim 1: Cartridge Orchestration Via Auto-Discovery
1818

19-
**Location**: `/var/mnt/eclipse/repos/boj-server/mcp-bridge/lib/cartridge-loader.ts` (TypeScript cartridge discovery and initialization)
19+
**Location**: `mcp-bridge/lib/cartridge-loader.ts` (TypeScript cartridge discovery and initialization)
2020

2121
**How verified**: The cartridge loader scans `cartridges/*/manifest.json`, reads tool schemas from each, and registers them dynamically with the MCP server. README (§Features) claims "50+ open-source cartridges." The loader validates each manifest, checks for required `name`, `version`, `tools` fields, and prevents duplicate tool names. This enables the "unified endpoint" claim: a single MCP server exposes the union of all cartridges' tools without hardcoding each one.
2222

2323
**Caveat**: Auto-discovery is runtime dynamic; there is no compile-time verification that all cartridge schemas are valid JSON Schema. A malformed manifest will error at MCP startup, not build time.
2424

2525
=== Claim 2: PanLL Grid Layout Auto-Wiring for Panel Cartridges
2626

27-
**Location**: `/var/mnt/eclipse/repos/boj-server/panll/lib/autowire.ts` (ReScript panel autowiring with constraint solver)
27+
**Location**: `panll/lib/autowire.ts` (ReScript panel autowiring with constraint solver)
2828

2929
**How verified**: The PanLL framework defines panels (UI widgets) with declared dependencies. The autowire module runs a topological sort + constraint satisfaction solver to bind panel inputs to outputs from other panels. README's panll/ subdir documents the "workspace layer" that orchestrates 108 panels into coherent layouts. The solver validates connectivity before rendering and rejects cycles.
3030

@@ -42,25 +42,26 @@ Cartridge pattern is reused in echidna (prover orchestration), gossamer (window
4242
|===
4343
| Path | What's There
4444

45-
| `mcp-bridge/lib/server.ts` | MCP server entry point; listens on stdio for Claude Code
46-
| `mcp-bridge/lib/cartridge-loader.ts` | Runtime cartridge discovery from manifests; dynamic tool registration
47-
| `mcp-bridge/lib/tool-mapper.ts` | Maps MCP tool calls to cartridge method invocations
48-
| `cartridges/*/manifest.json` | Cartridge metadata: name, version, tool schemas, credential requirements
49-
| `cartridges/github-api-mcp/lib/index.ts` | GitHub cartridge: repos, issues, PRs, code search via Octokit
50-
| `cartridges/slack-mcp/lib/index.ts` | Slack cartridge: messaging, channel management via bolt.js
51-
| `cartridges/cloudflare-mcp/lib/index.ts` | Cloudflare cartridge: DNS, Workers, KV, R2, D1 via Official SDK
52-
| `panll/lib/autowire.ts` | Panel grid layout solver; validates connectivity and prevents cycles
53-
| `panll/panels/*/manifest.json` | Panel declarations: inputs, outputs, size, dependencies
54-
| `lib/server.ts` | Elixir server entry point (port 7700); exposes REST API + cart ridge lifecycle
55-
| `lib/cartridge-manager.ex` | Elixir cartridge lifecycle: loading, initialization, error handling, credential validation
45+
| `mcp-bridge/main.js` | MCP server entry point; JSON-RPC stdio transport for Claude Code
46+
| `mcp-bridge/lib/security.js` | Prompt injection detection, rate limiting, input validation, error sanitization
47+
| `mcp-bridge/lib/api-clients.js` | GitHub, GitLab API passthroughs and BoJ REST API wrappers
48+
| `mcp-bridge/lib/tools.js` | MCP tool schema definitions for all cartridge tools
49+
| `mcp-bridge/lib/logger.js` | Structured JSON logging to stderr
50+
| `mcp-bridge/lib/version.js` | Single source of truth for server name and version
51+
| `mcp-bridge/lib/offline-menu.js` | Static cartridge manifest for offline/inspection mode
52+
| `cartridges/*/` | 96 cartridge directories, each with abi/, ffi/, adapter/ structure
53+
| `src/abi/Boj/` | Idris2 ABI definitions (Protocol, Domain, Catalogue, Safety, etc.)
54+
| `panll/` | ReScript/TEA panel framework for UI workspace layer
5655
|===
5756

5857
== Testing Critical Paths
5958

60-
* **Cartridge loading**: `tests/cartridge-loader.test.ts` — validates manifest parsing, schema validation, duplicate detection
61-
* **Tool dispatch**: `tests/tool-mapper.test.ts` — mocks cartridge methods, verifies correct routing from MCP calls
62-
* **Panel autowiring**: `tests/panel-autowire.test.ts` — constraint solver correctness with cyclic graph detection
63-
* **Integration**: `tests/integration/` — end-to-end MCP server ↔ cartridge invocation with real credentials (dry-run mode)
59+
* **Security module**: `tests/security_test.js` — injection detection, unicode bypass prevention, rate limiting, input validation
60+
* **Smoke tests**: `tests/smoke_test.ts` — CLI, MCP protocol, health check, cartridge schemas
61+
* **E2E tests**: `tests/e2e_mcp_test.ts` — MCP server lifecycle, tool invocation, error handling
62+
* **Property tests**: `tests/p2p_cartridge_properties_test.ts` — cartridge invariants, schema compliance
63+
* **Aspect security**: `tests/aspect_security_test.ts` — injection, sandboxing, credential handling, SSRF prevention
64+
* **Integration**: `tests/integration.sh` — end-to-end MCP server ↔ cartridge invocation
6465

6566
== Questions?
6667

Justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import? "contractile.just"
1919

2020
# Project metadata — customize these
2121
project := "Bundle of Joy Server"
22-
version := "0.1.0"
22+
version := "0.3.1"
2323
tier := "infrastructure" # 1 | 2 | infrastructure
2424

2525
# ═══════════════════════════════════════════════════════════════════════════════

TOPOLOGY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SPDX-License-Identifier: PMPL-1.0-or-later
22
# TOPOLOGY.md — BoJ Server Component Matrix
33
#
4-
# Auto-generated 2026-03-29. 92 cartridges across 6 tiers.
4+
# Auto-generated 2026-04-09. 96 cartridges across 6 tiers.
55

66
## Ports
77

@@ -12,7 +12,7 @@
1212
| 7702 | GraphQL | Running |
1313
| 7703 | SSE (Server-Sent Events) | Running |
1414

15-
## Cartridge Matrix (92 total)
15+
## Cartridge Matrix (96 total)
1616

1717
### Tier 1 — High-Value APIs (11)
1818
| Cartridge | Domain |

mcp-bridge/.eslintrc.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"env": {
3+
"node": true,
4+
"es2022": true
5+
},
6+
"parserOptions": {
7+
"ecmaVersion": 2022,
8+
"sourceType": "module"
9+
},
10+
"rules": {
11+
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
12+
"no-undef": "error",
13+
"no-var": "error",
14+
"prefer-const": "warn",
15+
"eqeqeq": ["error", "always"],
16+
"no-eval": "error",
17+
"no-implied-eval": "error",
18+
"no-new-func": "error",
19+
"no-return-await": "warn",
20+
"no-throw-literal": "error",
21+
"no-shadow": "warn",
22+
"curly": ["warn", "multi-line"]
23+
}
24+
}

mcp-bridge/lib/api-clients.js

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// BoJ Server — API client module
5+
//
6+
// Direct passthrough clients for GitHub and GitLab APIs, plus
7+
// BoJ REST API wrappers for cartridge operations.
8+
9+
import { isValidCartridgeName } from "./security.js";
10+
import { warn } from "./logger.js";
11+
import { SERVER_VERSION } from "./version.js";
12+
13+
const BOJ_BASE = process.env.BOJ_URL || "http://localhost:7700";
14+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
15+
const GITLAB_TOKEN = process.env.GITLAB_TOKEN || "";
16+
17+
// ===================================================================
18+
// BoJ REST API wrappers
19+
// ===================================================================
20+
21+
/** @returns {Promise<object>} */
22+
async function fetchHealth() {
23+
try {
24+
const res = await fetch(`${BOJ_BASE}/health`);
25+
return await res.json();
26+
} catch {
27+
return { status: "offline", message: "BoJ REST API not reachable. Start the server with: systemctl --user start boj-server" };
28+
}
29+
}
30+
31+
/** @returns {Promise<object>} */
32+
async function fetchMenu() {
33+
try {
34+
const res = await fetch(`${BOJ_BASE}/menu`);
35+
return await res.json();
36+
} catch {
37+
warn("BoJ REST API unreachable, using offline menu");
38+
const { OFFLINE_MENU } = await import("./offline-menu.js");
39+
return OFFLINE_MENU;
40+
}
41+
}
42+
43+
/** @returns {Promise<object>} */
44+
async function fetchCartridges() {
45+
try {
46+
const res = await fetch(`${BOJ_BASE}/cartridges`);
47+
return await res.json();
48+
} catch {
49+
const { OFFLINE_MENU } = await import("./offline-menu.js");
50+
return {
51+
note: "Offline mode — cartridge matrix available when BoJ REST API is running",
52+
cartridges: Object.keys(
53+
OFFLINE_MENU.tier_teranga
54+
.concat(OFFLINE_MENU.tier_shield)
55+
.reduce((acc, c) => { acc[c.name] = c.domain; return acc; }, {})
56+
),
57+
};
58+
}
59+
}
60+
61+
/**
62+
* @param {string} name
63+
* @param {object} [params]
64+
* @returns {Promise<object>}
65+
*/
66+
async function invokeCartridge(name, params) {
67+
if (!isValidCartridgeName(name)) {
68+
return { error: `Invalid cartridge name: ${name}` };
69+
}
70+
try {
71+
const res = await fetch(`${BOJ_BASE}/cartridge/${encodeURIComponent(name)}/invoke`, {
72+
method: "POST",
73+
headers: { "Content-Type": "application/json" },
74+
body: JSON.stringify(params || {}),
75+
});
76+
return await res.json();
77+
} catch {
78+
return { error: "BoJ REST API not reachable. Invocation requires a running server.", cartridge: name, hint: "Start with: systemctl --user start boj-server" };
79+
}
80+
}
81+
82+
/**
83+
* @param {string} name
84+
* @returns {Promise<object>}
85+
*/
86+
async function fetchCartridgeInfo(name) {
87+
if (!isValidCartridgeName(name)) {
88+
return { error: `Invalid cartridge name: ${name}` };
89+
}
90+
try {
91+
const res = await fetch(`${BOJ_BASE}/cartridge/${encodeURIComponent(name)}`);
92+
return await res.json();
93+
} catch {
94+
const { OFFLINE_MENU } = await import("./offline-menu.js");
95+
const all = OFFLINE_MENU.tier_teranga.concat(OFFLINE_MENU.tier_shield);
96+
const found = all.find(c => c.name === name);
97+
return found || { error: `Unknown cartridge: ${name}` };
98+
}
99+
}
100+
101+
// ===================================================================
102+
// GitHub API
103+
// ===================================================================
104+
105+
/**
106+
* @param {string} method
107+
* @param {string} path
108+
* @param {object} [body]
109+
* @returns {Promise<object>}
110+
*/
111+
async function githubApiCall(method, path, body) {
112+
if (!GITHUB_TOKEN) {
113+
return { error: "GITHUB_TOKEN not set. Store in vault-mcp or export to environment." };
114+
}
115+
try {
116+
const url = `https://api.github.com${path}`;
117+
const opts = {
118+
method,
119+
headers: {
120+
"Authorization": `Bearer ${GITHUB_TOKEN}`,
121+
"Accept": "application/vnd.github+json",
122+
"X-GitHub-Api-Version": "2022-11-28",
123+
"User-Agent": `boj-server/${SERVER_VERSION}`,
124+
},
125+
};
126+
if (body && method !== "GET") {
127+
opts.headers["Content-Type"] = "application/json";
128+
opts.body = JSON.stringify(body);
129+
}
130+
const res = await fetch(url, opts);
131+
const data = await res.json();
132+
const rateLimit = {
133+
remaining: res.headers.get("x-ratelimit-remaining"),
134+
reset: res.headers.get("x-ratelimit-reset"),
135+
limit: res.headers.get("x-ratelimit-limit"),
136+
};
137+
return { status: res.status, data, rateLimit };
138+
} catch (err) {
139+
return { error: `GitHub API error: ${err.message}` };
140+
}
141+
}
142+
143+
/**
144+
* @param {string} query
145+
* @param {object} [variables]
146+
* @returns {Promise<object>}
147+
*/
148+
async function githubGraphQL(query, variables) {
149+
if (!GITHUB_TOKEN) {
150+
return { error: "GITHUB_TOKEN not set." };
151+
}
152+
try {
153+
const res = await fetch("https://api.github.com/graphql", {
154+
method: "POST",
155+
headers: {
156+
"Authorization": `Bearer ${GITHUB_TOKEN}`,
157+
"Content-Type": "application/json",
158+
"User-Agent": `boj-server/${SERVER_VERSION}`,
159+
},
160+
body: JSON.stringify({ query, variables: variables || {} }),
161+
});
162+
return await res.json();
163+
} catch (err) {
164+
return { error: `GitHub GraphQL error: ${err.message}` };
165+
}
166+
}
167+
168+
/**
169+
* Route GitHub tool calls to real API.
170+
* @param {string} toolName
171+
* @param {Record<string, any>} args
172+
* @returns {Promise<object>}
173+
*/
174+
async function handleGitHubTool(toolName, args) {
175+
switch (toolName) {
176+
case "boj_github_list_repos":
177+
return githubApiCall("GET", `/user/repos?per_page=${args.per_page || 30}&sort=${args.sort || "updated"}`);
178+
case "boj_github_get_repo":
179+
return githubApiCall("GET", `/repos/${args.owner}/${args.repo}`);
180+
case "boj_github_create_issue":
181+
return githubApiCall("POST", `/repos/${args.owner}/${args.repo}/issues`, { title: args.title, body: args.body, labels: args.labels });
182+
case "boj_github_list_issues":
183+
return githubApiCall("GET", `/repos/${args.owner}/${args.repo}/issues?state=${args.state || "open"}&per_page=${args.per_page || 30}`);
184+
case "boj_github_get_issue":
185+
return githubApiCall("GET", `/repos/${args.owner}/${args.repo}/issues/${args.issue_number}`);
186+
case "boj_github_comment_issue":
187+
return githubApiCall("POST", `/repos/${args.owner}/${args.repo}/issues/${args.issue_number}/comments`, { body: args.body });
188+
case "boj_github_create_pr":
189+
return githubApiCall("POST", `/repos/${args.owner}/${args.repo}/pulls`, { title: args.title, body: args.body, head: args.head, base: args.base || "main" });
190+
case "boj_github_list_prs":
191+
return githubApiCall("GET", `/repos/${args.owner}/${args.repo}/pulls?state=${args.state || "open"}`);
192+
case "boj_github_get_pr":
193+
return githubApiCall("GET", `/repos/${args.owner}/${args.repo}/pulls/${args.pull_number}`);
194+
case "boj_github_merge_pr":
195+
return githubApiCall("PUT", `/repos/${args.owner}/${args.repo}/pulls/${args.pull_number}/merge`, { merge_method: args.method || "merge" });
196+
case "boj_github_search_code":
197+
return githubApiCall("GET", `/search/code?q=${encodeURIComponent(args.query)}`);
198+
case "boj_github_search_issues":
199+
return githubApiCall("GET", `/search/issues?q=${encodeURIComponent(args.query)}`);
200+
case "boj_github_get_file":
201+
return githubApiCall("GET", `/repos/${args.owner}/${args.repo}/contents/${args.path}?ref=${args.ref || "main"}`);
202+
case "boj_github_graphql":
203+
return githubGraphQL(args.query, args.variables);
204+
default:
205+
return { error: `Unknown GitHub tool: ${toolName}` };
206+
}
207+
}
208+
209+
// ===================================================================
210+
// GitLab API
211+
// ===================================================================
212+
213+
/**
214+
* @param {string} method
215+
* @param {string} path
216+
* @param {object} [body]
217+
* @returns {Promise<object>}
218+
*/
219+
async function gitlabApiCall(method, path, body) {
220+
if (!GITLAB_TOKEN) {
221+
return { error: "GITLAB_TOKEN not set." };
222+
}
223+
const baseUrl = process.env.GITLAB_URL || "https://gitlab.com";
224+
try {
225+
const url = `${baseUrl}/api/v4${path}`;
226+
const opts = {
227+
method,
228+
headers: {
229+
"PRIVATE-TOKEN": GITLAB_TOKEN,
230+
"Accept": "application/json",
231+
"User-Agent": `boj-server/${SERVER_VERSION}`,
232+
},
233+
};
234+
if (body && method !== "GET") {
235+
opts.headers["Content-Type"] = "application/json";
236+
opts.body = JSON.stringify(body);
237+
}
238+
const res = await fetch(url, opts);
239+
const data = await res.json();
240+
return { status: res.status, data };
241+
} catch (err) {
242+
return { error: `GitLab API error: ${err.message}` };
243+
}
244+
}
245+
246+
/**
247+
* Route GitLab tool calls to real API.
248+
* @param {string} toolName
249+
* @param {Record<string, any>} args
250+
* @returns {Promise<object>}
251+
*/
252+
async function handleGitLabTool(toolName, args) {
253+
switch (toolName) {
254+
case "boj_gitlab_list_projects":
255+
return gitlabApiCall("GET", `/projects?owned=true&per_page=${args.per_page || 20}`);
256+
case "boj_gitlab_get_project":
257+
return gitlabApiCall("GET", `/projects/${encodeURIComponent(args.project_id)}`);
258+
case "boj_gitlab_create_issue":
259+
return gitlabApiCall("POST", `/projects/${encodeURIComponent(args.project_id)}/issues`, { title: args.title, description: args.description });
260+
case "boj_gitlab_list_issues":
261+
return gitlabApiCall("GET", `/projects/${encodeURIComponent(args.project_id)}/issues?state=${args.state || "opened"}`);
262+
case "boj_gitlab_create_mr":
263+
return gitlabApiCall("POST", `/projects/${encodeURIComponent(args.project_id)}/merge_requests`, { title: args.title, source_branch: args.source, target_branch: args.target || "main" });
264+
case "boj_gitlab_list_mrs":
265+
return gitlabApiCall("GET", `/projects/${encodeURIComponent(args.project_id)}/merge_requests?state=${args.state || "opened"}`);
266+
case "boj_gitlab_list_pipelines":
267+
return gitlabApiCall("GET", `/projects/${encodeURIComponent(args.project_id)}/pipelines`);
268+
case "boj_gitlab_setup_mirror":
269+
return gitlabApiCall("POST", `/projects/${encodeURIComponent(args.project_id)}/remote_mirrors`, { url: args.target_url, enabled: true });
270+
default:
271+
return { error: `Unknown GitLab tool: ${toolName}` };
272+
}
273+
}
274+
275+
export {
276+
BOJ_BASE,
277+
fetchCartridgeInfo,
278+
fetchCartridges,
279+
fetchHealth,
280+
fetchMenu,
281+
handleGitHubTool,
282+
handleGitLabTool,
283+
invokeCartridge,
284+
};

0 commit comments

Comments
 (0)