Skip to content

Commit 0093051

Browse files
hyperpolymathclaude
andcommitted
feat(adr-0006): migrate stapeln-mcp + hypatia-mcp to 5-symbol ABI
Third and fourth cartridges on the ADR-0006 ABI after aerie-mcp and ml-mcp. Both were pure-stub (4 bespoke exports, zero standard ABI) so each gained all 5 standard symbols plus shim-backed dispatch. - stapeln-mcp: dispatches stapeln_list_stacks / stapeln_deploy / stapeln_scale / stapeln_get_health. Tests: 8/8. - hypatia-mcp: dispatches hypatia_scan_repo / hypatia_get_score / hypatia_get_rule_set / hypatia_train_model. Tests: 8/8. (Note: cartridge.json declares `hypatia_get_rule_set`; bespoke FFI symbol is `hypatia_get_rule_count` — invoke uses the cartridge.json name as the canonical tool surface.) Pattern validated across three distinct shapes: ml-mcp (existing 4-symbol baseline + rich session state), aerie-mcp (reference), and these two pure-stub cartridges. Per-cartridge shim import costs 4 lines of build.zig + ~35 LOC of dispatch — well under the 60-120 LOC the ADR anticipated. This unlocks the Elixir router `boj_cartridge_invoke` CLI verb (ADR requires more than one cartridge implementing dispatch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a5f9435 commit 0093051

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

cartridges/hypatia-mcp/ffi/build.zig

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,18 @@ pub fn build(b: *std.Build) void {
77
const target = b.standardTargetOptions(.{});
88
const optimize = b.standardOptimizeOption(.{});
99

10+
const shim_mod = b.addModule("cartridge_shim", .{
11+
.root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"),
12+
.target = target,
13+
.optimize = optimize,
14+
});
15+
1016
const ffi_mod = b.addModule("hypatia_mcp", .{
1117
.root_source_file = b.path("hypatia_ffi.zig"),
1218
.target = target,
1319
.optimize = optimize,
1420
});
21+
ffi_mod.addImport("cartridge_shim", shim_mod);
1522

1623
const lib = b.addLibrary(.{
1724
.name = "hypatia_mcp",

cartridges/hypatia-mcp/ffi/hypatia_ffi.zig

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,54 @@ export fn hypatia_get_rule_count() u32 {
2828
return 17; // Stub — matches standard workflow set
2929
}
3030

31+
// ── Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) ──────────
32+
33+
const shim = @import("cartridge_shim");
34+
35+
const CARTRIDGE_NAME_PTR: [*:0]const u8 = "hypatia-mcp";
36+
const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0";
37+
38+
export fn boj_cartridge_init() callconv(.c) c_int {
39+
return 0;
40+
}
41+
42+
export fn boj_cartridge_deinit() callconv(.c) void {}
43+
44+
export fn boj_cartridge_name() callconv(.c) [*:0]const u8 {
45+
return CARTRIDGE_NAME_PTR;
46+
}
47+
48+
export fn boj_cartridge_version() callconv(.c) [*:0]const u8 {
49+
return CARTRIDGE_VERSION_PTR;
50+
}
51+
52+
/// Dispatch the 4 cartridge.json MCP tools. Grade D Alpha stubs.
53+
/// Note: `hypatia_get_rule_set` is the declared tool name; the bespoke
54+
/// FFI symbol is `hypatia_get_rule_count` — the invoke dispatch uses
55+
/// the cartridge.json-declared name as its canonical surface.
56+
export fn boj_cartridge_invoke(
57+
tool_name: [*c]const u8,
58+
json_args: [*c]const u8,
59+
out_buf: [*c]u8,
60+
in_out_len: [*c]usize,
61+
) callconv(.c) i32 {
62+
_ = json_args;
63+
if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS;
64+
65+
const body: []const u8 = if (shim.toolIs(tool_name, "hypatia_scan_repo"))
66+
"{\"result\":{\"scan_id\":1,\"status\":\"stub\"}}"
67+
else if (shim.toolIs(tool_name, "hypatia_get_score"))
68+
"{\"result\":{\"score\":85,\"status\":\"stub\"}}"
69+
else if (shim.toolIs(tool_name, "hypatia_get_rule_set"))
70+
"{\"result\":{\"rules\":[],\"count\":17,\"status\":\"stub\"}}"
71+
else if (shim.toolIs(tool_name, "hypatia_train_model"))
72+
"{\"result\":{\"training\":true,\"status\":\"stub\"}}"
73+
else
74+
return shim.RC_UNKNOWN_TOOL;
75+
76+
return shim.writeResult(out_buf, in_out_len, body);
77+
}
78+
3179
// ── Tests ──
3280

3381
test "scan rejects null path" {
@@ -42,3 +90,41 @@ test "score within bounds" {
4290
test "rule count is positive" {
4391
try std.testing.expect(hypatia_get_rule_count() > 0);
4492
}
93+
94+
test "boj_cartridge_name returns hypatia-mcp" {
95+
const n = std.mem.span(boj_cartridge_name());
96+
try std.testing.expectEqualStrings("hypatia-mcp", n);
97+
}
98+
99+
test "boj_cartridge_init returns 0" {
100+
try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init());
101+
}
102+
103+
test "invoke: each declared tool succeeds" {
104+
var buf: [256]u8 = undefined;
105+
const tools = [_][]const u8{
106+
"hypatia_scan_repo", "hypatia_get_score",
107+
"hypatia_get_rule_set", "hypatia_train_model",
108+
};
109+
for (tools) |t| {
110+
var len: usize = buf.len;
111+
const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len);
112+
try std.testing.expectEqual(@as(i32, 0), rc);
113+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null);
114+
}
115+
}
116+
117+
test "invoke: unknown tool returns -1" {
118+
var buf: [64]u8 = undefined;
119+
var len: usize = buf.len;
120+
const rc = boj_cartridge_invoke("nope", "{}", &buf, &len);
121+
try std.testing.expectEqual(@as(i32, -1), rc);
122+
}
123+
124+
test "invoke: buffer too small returns -3" {
125+
var buf: [4]u8 = undefined;
126+
var len: usize = buf.len;
127+
const rc = boj_cartridge_invoke("hypatia_scan_repo", "{}", &buf, &len);
128+
try std.testing.expectEqual(@as(i32, -3), rc);
129+
try std.testing.expect(len > 4);
130+
}

cartridges/stapeln-mcp/ffi/build.zig

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,18 @@ pub fn build(b: *std.Build) void {
77
const target = b.standardTargetOptions(.{});
88
const optimize = b.standardOptimizeOption(.{});
99

10+
const shim_mod = b.addModule("cartridge_shim", .{
11+
.root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"),
12+
.target = target,
13+
.optimize = optimize,
14+
});
15+
1016
const ffi_mod = b.addModule("stapeln_mcp", .{
1117
.root_source_file = b.path("stapeln_ffi.zig"),
1218
.target = target,
1319
.optimize = optimize,
1420
});
21+
ffi_mod.addImport("cartridge_shim", shim_mod);
1522

1623
const lib = b.addLibrary(.{
1724
.name = "stapeln_mcp",

cartridges/stapeln-mcp/ffi/stapeln_ffi.zig

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,52 @@ export fn stapeln_get_health(name: [*c]const u8) u8 {
2929
return 0; // Stub
3030
}
3131

32+
// ── Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) ──────────
33+
34+
const shim = @import("cartridge_shim");
35+
36+
const CARTRIDGE_NAME_PTR: [*:0]const u8 = "stapeln-mcp";
37+
const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0";
38+
39+
export fn boj_cartridge_init() callconv(.c) c_int {
40+
return 0;
41+
}
42+
43+
export fn boj_cartridge_deinit() callconv(.c) void {}
44+
45+
export fn boj_cartridge_name() callconv(.c) [*:0]const u8 {
46+
return CARTRIDGE_NAME_PTR;
47+
}
48+
49+
export fn boj_cartridge_version() callconv(.c) [*:0]const u8 {
50+
return CARTRIDGE_VERSION_PTR;
51+
}
52+
53+
/// Dispatch the 4 cartridge.json MCP tools. Grade D Alpha stubs:
54+
/// each arm returns JSON that reflects the tool's intended shape.
55+
export fn boj_cartridge_invoke(
56+
tool_name: [*c]const u8,
57+
json_args: [*c]const u8,
58+
out_buf: [*c]u8,
59+
in_out_len: [*c]usize,
60+
) callconv(.c) i32 {
61+
_ = json_args;
62+
if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS;
63+
64+
const body: []const u8 = if (shim.toolIs(tool_name, "stapeln_list_stacks"))
65+
"{\"result\":{\"stacks\":[],\"count\":0,\"status\":\"stub\"}}"
66+
else if (shim.toolIs(tool_name, "stapeln_deploy"))
67+
"{\"result\":{\"deployed\":true,\"status\":\"stub\"}}"
68+
else if (shim.toolIs(tool_name, "stapeln_scale"))
69+
"{\"result\":{\"scaled\":true,\"status\":\"stub\"}}"
70+
else if (shim.toolIs(tool_name, "stapeln_get_health"))
71+
"{\"result\":{\"health\":\"healthy\",\"status\":\"stub\"}}"
72+
else
73+
return shim.RC_UNKNOWN_TOOL;
74+
75+
return shim.writeResult(out_buf, in_out_len, body);
76+
}
77+
3278
// ── Tests ──
3379

3480
test "deploy rejects null name" {
@@ -42,3 +88,41 @@ test "deploy rejects zero replicas" {
4288
test "health returns unknown for null" {
4389
try std.testing.expectEqual(@as(u8, 3), stapeln_get_health(null));
4490
}
91+
92+
test "boj_cartridge_name returns stapeln-mcp" {
93+
const n = std.mem.span(boj_cartridge_name());
94+
try std.testing.expectEqualStrings("stapeln-mcp", n);
95+
}
96+
97+
test "boj_cartridge_init returns 0" {
98+
try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init());
99+
}
100+
101+
test "invoke: each declared tool succeeds" {
102+
var buf: [256]u8 = undefined;
103+
const tools = [_][]const u8{
104+
"stapeln_list_stacks", "stapeln_deploy",
105+
"stapeln_scale", "stapeln_get_health",
106+
};
107+
for (tools) |t| {
108+
var len: usize = buf.len;
109+
const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len);
110+
try std.testing.expectEqual(@as(i32, 0), rc);
111+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null);
112+
}
113+
}
114+
115+
test "invoke: unknown tool returns -1" {
116+
var buf: [64]u8 = undefined;
117+
var len: usize = buf.len;
118+
const rc = boj_cartridge_invoke("nope", "{}", &buf, &len);
119+
try std.testing.expectEqual(@as(i32, -1), rc);
120+
}
121+
122+
test "invoke: buffer too small returns -3" {
123+
var buf: [4]u8 = undefined;
124+
var len: usize = buf.len;
125+
const rc = boj_cartridge_invoke("stapeln_list_stacks", "{}", &buf, &len);
126+
try std.testing.expectEqual(@as(i32, -3), rc);
127+
try std.testing.expect(len > 4);
128+
}

0 commit comments

Comments
 (0)