feat: C FFI + Go cgo bindings for Rust MCP tools, fix TS conformance

Rust FFI (rust/src/ffi.rs):
- Expose all 13 HIP-0300 tools via C-compatible API
- 7 extern "C" functions: init, call_tool, list_tools, last_error,
  free_string, destroy, version
- Persistent multi-threaded tokio runtime behind opaque handle
- panic::catch_unwind on all FFI boundaries
- Wire 5 missing tools (code, git, fetch, workspace, tasks, hanzo)
  into ToolRegistry::execute() match arms
- Build as cdylib + staticlib (libhanzo_mcp.a / .dylib)

Go bindings (go/hanzomcp/):
- Handle type with Init/CallTool/CallToolRaw/ListTools/Close
- JSON in, JSON out across cgo boundary
- runtime.SetFinalizer safety net
- 11 tests including 20-goroutine concurrent stress test
- Race detector clean

TypeScript fixes:
- tasks: handle stale todos.json with wrong schema (todos vs items)
- code: add symbols + summarize actions for Python parity
- fetch: add fetch + head actions for Python parity

Results: Rust 66/66 tests, Go 11/11 tests, TS 22/22 conformance
This commit is contained in:
2026-03-11 11:37:15 -07:00
parent 3dd4484644
commit e9a4d37d1f
11 changed files with 1078 additions and 14 deletions
+27
View File
@@ -0,0 +1,27 @@
RUST_DIR := ../rust
LIB_DIR := $(RUST_DIR)/target/release
.PHONY: all build-rust test clean
all: build-rust test
## Build the Rust static + shared library (release mode)
build-rust:
cd $(RUST_DIR) && cargo build --release
## Run Go tests (requires build-rust first)
test: build-rust
CGO_ENABLED=1 go test -v ./hanzomcp/...
## Run Go tests with race detector
test-race: build-rust
CGO_ENABLED=1 go test -race -v ./hanzomcp/...
## Quick benchmark
bench: build-rust
CGO_ENABLED=1 go test -bench=. -benchtime=3s ./hanzomcp/...
## Clean build artifacts
clean:
cd $(RUST_DIR) && cargo clean
go clean -cache
+3
View File
@@ -0,0 +1,3 @@
module github.com/hanzoai/mcp/go
go 1.22
+204
View File
@@ -0,0 +1,204 @@
// Package hanzomcp provides Go bindings to the Hanzo MCP tool runtime via cgo.
//
// It wraps the Rust libhanzo_mcp library, giving Go agents in-process access
// to all 13 HIP-0300 tools (fs, exec, code, git, fetch, workspace, computer,
// think, memory, hanzo, plan, tasks, mode) with zero-copy JSON marshalling.
//
// Usage:
//
// h, err := hanzomcp.Init(nil)
// if err != nil { log.Fatal(err) }
// defer h.Close()
//
// result, err := h.CallTool("think", map[string]any{
// "action": "think",
// "thought": "Planning next steps...",
// })
// fmt.Println(result.Success, result.Content)
package hanzomcp
/*
#cgo LDFLAGS: -L${SRCDIR}/../../rust/target/release -lhanzo_mcp
#cgo LDFLAGS: -ldl -lm -lpthread
#cgo darwin LDFLAGS: -framework CoreGraphics -framework CoreFoundation -framework Cocoa -framework Security
#cgo linux LDFLAGS: -lX11
#include "../../rust/include/hanzo_mcp.h"
#include <stdlib.h>
*/
import "C"
import (
"encoding/json"
"errors"
"fmt"
"runtime"
"sync"
"unsafe"
)
// Handle wraps an opaque pointer to the Rust MCP tool runtime.
// It is safe to use from multiple goroutines concurrently.
type Handle struct {
ptr C.HanzoMCPHandle
mu sync.Mutex
once sync.Once
}
// ToolResult is the result of a tool call.
type ToolResult struct {
Success bool `json:"success"`
Content json.RawMessage `json:"content"`
Error string `json:"error,omitempty"`
}
// ToolDef describes a tool's name, description, and parameter schema.
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
// Init creates a new MCP tool runtime with all 13 HIP-0300 tools.
//
// Pass nil for default configuration, or a map with configuration options.
// The returned Handle must be closed with Close() when done.
func Init(config map[string]any) (*Handle, error) {
var configPtr *C.char
if config != nil {
data, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("hanzomcp: invalid config: %w", err)
}
configPtr = C.CString(string(data))
defer C.free(unsafe.Pointer(configPtr))
}
ptr := C.hanzo_mcp_init(configPtr)
if ptr == nil {
return nil, fmt.Errorf("hanzomcp: init failed: %s", lastError())
}
h := &Handle{ptr: ptr}
runtime.SetFinalizer(h, (*Handle).Close)
return h, nil
}
// CallTool executes a tool by name with the given parameters.
//
// The params argument is marshalled to JSON. Common usage:
//
// result, err := h.CallTool("fs", map[string]any{
// "action": "read",
// "path": "/tmp/hello.txt",
// })
//
// This method blocks until the tool completes. It is safe to call
// from multiple goroutines concurrently.
func (h *Handle) CallTool(name string, params any) (*ToolResult, error) {
if h.ptr == nil {
return nil, errors.New("hanzomcp: handle is closed")
}
paramsJSON := "{}"
if params != nil {
data, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("hanzomcp: invalid params: %w", err)
}
paramsJSON = string(data)
}
cName := C.CString(name)
defer C.free(unsafe.Pointer(cName))
cParams := C.CString(paramsJSON)
defer C.free(unsafe.Pointer(cParams))
cResult := C.hanzo_mcp_call_tool(h.ptr, cName, cParams)
if cResult == nil {
return nil, fmt.Errorf("hanzomcp: call_tool panic: %s", lastError())
}
defer C.hanzo_mcp_free_string(cResult)
var result ToolResult
if err := json.Unmarshal([]byte(C.GoString(cResult)), &result); err != nil {
return nil, fmt.Errorf("hanzomcp: invalid result JSON: %w", err)
}
return &result, nil
}
// CallToolRaw is like CallTool but accepts and returns raw JSON bytes,
// avoiding double-marshalling when the caller already has JSON.
func (h *Handle) CallToolRaw(name string, paramsJSON []byte) ([]byte, error) {
if h.ptr == nil {
return nil, errors.New("hanzomcp: handle is closed")
}
cName := C.CString(name)
defer C.free(unsafe.Pointer(cName))
cParams := C.CString(string(paramsJSON))
defer C.free(unsafe.Pointer(cParams))
cResult := C.hanzo_mcp_call_tool(h.ptr, cName, cParams)
if cResult == nil {
return nil, fmt.Errorf("hanzomcp: call_tool panic: %s", lastError())
}
defer C.hanzo_mcp_free_string(cResult)
return []byte(C.GoString(cResult)), nil
}
// ListTools returns definitions for all available tools.
func (h *Handle) ListTools() ([]ToolDef, error) {
if h.ptr == nil {
return nil, errors.New("hanzomcp: handle is closed")
}
cResult := C.hanzo_mcp_list_tools(h.ptr)
if cResult == nil {
return nil, fmt.Errorf("hanzomcp: list_tools failed: %s", lastError())
}
defer C.hanzo_mcp_free_string(cResult)
var tools []ToolDef
if err := json.Unmarshal([]byte(C.GoString(cResult)), &tools); err != nil {
return nil, fmt.Errorf("hanzomcp: invalid tools JSON: %w", err)
}
return tools, nil
}
// Version returns version information as a map.
func Version() (map[string]any, error) {
cResult := C.hanzo_mcp_version()
if cResult == nil {
return nil, errors.New("hanzomcp: version failed")
}
defer C.hanzo_mcp_free_string(cResult)
var v map[string]any
if err := json.Unmarshal([]byte(C.GoString(cResult)), &v); err != nil {
return nil, fmt.Errorf("hanzomcp: invalid version JSON: %w", err)
}
return v, nil
}
// Close destroys the handle and releases all resources.
// Safe to call multiple times. Must not be called while CallTool is in progress.
func (h *Handle) Close() {
h.once.Do(func() {
h.mu.Lock()
defer h.mu.Unlock()
if h.ptr != nil {
C.hanzo_mcp_destroy(h.ptr)
h.ptr = nil
}
})
}
func lastError() string {
cErr := C.hanzo_mcp_last_error()
if cErr == nil {
return "(unknown error)"
}
defer C.hanzo_mcp_free_string(cErr)
return C.GoString(cErr)
}
+217
View File
@@ -0,0 +1,217 @@
package hanzomcp
import (
"encoding/json"
"fmt"
"testing"
)
func TestInitAndClose(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
h.Close()
}
func TestDoubleClose(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
h.Close()
h.Close() // should not panic
}
func TestVersion(t *testing.T) {
v, err := Version()
if err != nil {
t.Fatalf("Version failed: %v", err)
}
if v["name"] != "hanzo-mcp" {
t.Errorf("expected name=hanzo-mcp, got %v", v["name"])
}
}
func TestListTools(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
defer h.Close()
tools, err := h.ListTools()
if err != nil {
t.Fatalf("ListTools failed: %v", err)
}
if len(tools) < 13 {
t.Errorf("expected >= 13 tools, got %d", len(tools))
}
// Check canonical tool names
names := make(map[string]bool)
for _, tool := range tools {
names[tool.Name] = true
}
canonical := []string{"fs", "exec", "code", "git", "fetch", "workspace",
"think", "memory", "plan", "tasks", "mode", "computer"}
for _, name := range canonical {
if !names[name] {
t.Errorf("missing canonical tool: %s", name)
}
}
}
func TestCallToolThink(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
defer h.Close()
result, err := h.CallTool("think", map[string]any{
"action": "think",
"thought": "Go cgo FFI test",
})
if err != nil {
t.Fatalf("CallTool failed: %v", err)
}
if !result.Success {
t.Errorf("expected success, got error: %s", result.Error)
}
}
func TestCallToolFsHelp(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
defer h.Close()
result, err := h.CallTool("fs", map[string]any{
"action": "help",
})
if err != nil {
t.Fatalf("CallTool failed: %v", err)
}
if !result.Success {
t.Errorf("expected success, got error: %s", result.Error)
}
}
func TestCallToolExec(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
defer h.Close()
result, err := h.CallTool("exec", map[string]any{
"action": "exec",
"command": "echo hello-from-go",
})
if err != nil {
t.Fatalf("CallTool failed: %v", err)
}
if !result.Success {
t.Errorf("expected success, got error: %s", result.Error)
}
// Check output contains our string
var content map[string]any
if err := json.Unmarshal(result.Content, &content); err == nil {
if stdout, ok := content["stdout"].(string); ok {
if stdout == "" {
t.Error("expected stdout to contain output")
}
}
}
}
func TestCallToolUnknown(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
defer h.Close()
result, err := h.CallTool("nonexistent_tool", map[string]any{})
if err != nil {
t.Fatalf("CallTool should not error for unknown tool: %v", err)
}
if result.Success {
t.Error("expected failure for unknown tool")
}
}
func TestCallToolRaw(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
defer h.Close()
raw, err := h.CallToolRaw("think", []byte(`{"action":"think","thought":"raw test"}`))
if err != nil {
t.Fatalf("CallToolRaw failed: %v", err)
}
var result ToolResult
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if !result.Success {
t.Errorf("expected success: %s", result.Error)
}
}
func TestCallToolClosedHandle(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
h.Close()
_, err = h.CallTool("think", map[string]any{"action": "help"})
if err == nil {
t.Error("expected error for closed handle")
}
}
func TestConcurrentCalls(t *testing.T) {
h, err := Init(nil)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
defer h.Close()
const n = 20
errs := make(chan error, n)
for i := 0; i < n; i++ {
go func(i int) {
result, err := h.CallTool("think", map[string]any{
"action": "think",
"thought": "concurrent test",
})
if err != nil {
errs <- err
return
}
if !result.Success {
errs <- fmt.Errorf("call %d failed: %s", i, result.Error)
return
}
errs <- nil
}(i)
}
for i := 0; i < n; i++ {
if err := <-errs; err != nil {
t.Errorf("concurrent call failed: %v", err)
}
}
}
+1
View File
@@ -86,3 +86,4 @@ path = "src/main.rs"
[lib]
name = "hanzo_mcp"
path = "src/lib.rs"
crate-type = ["lib", "cdylib", "staticlib"]
+101
View File
@@ -0,0 +1,101 @@
/*
* Hanzo MCP Tools — C API
*
* Provides in-process access to all 13 HIP-0300 MCP tools from any language
* with C FFI support (Go via cgo, Python via ctypes, Ruby via FFI, etc.).
*
* Thread safety: All functions are safe to call from any OS thread.
* Memory: Strings returned by this library must be freed with hanzo_mcp_free_string().
*
* Generated from hanzo-mcp Rust crate. Do not edit manually.
*/
#ifndef HANZO_MCP_H
#define HANZO_MCP_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* Opaque handle to an MCP tool runtime (tokio runtime + tool registry).
*/
typedef void* HanzoMCPHandle;
/**
* Initialize the Hanzo MCP tool runtime.
*
* Creates a multi-threaded async runtime and registers all 13 HIP-0300 tools:
* fs, exec, code, git, fetch, workspace, computer, think, memory, hanzo, plan, tasks, mode.
*
* @param config_json Optional JSON configuration (may be NULL for defaults).
* @return Opaque handle, or NULL on failure (call hanzo_mcp_last_error()).
*
* The returned handle must be destroyed with hanzo_mcp_destroy().
*/
HanzoMCPHandle hanzo_mcp_init(const char* config_json);
/**
* Call a tool by name with JSON parameters.
*
* Blocks the calling thread until the tool completes.
*
* @param handle Handle from hanzo_mcp_init().
* @param tool_name Tool name (e.g., "fs", "exec", "think").
* @param params_json JSON object with tool parameters.
* @return JSON string with result: {"success":bool,"content":...,"error":...}.
* Caller must free with hanzo_mcp_free_string().
* Returns NULL only on panic (call hanzo_mcp_last_error()).
*/
char* hanzo_mcp_call_tool(HanzoMCPHandle handle,
const char* tool_name,
const char* params_json);
/**
* List all available tools.
*
* @param handle Handle from hanzo_mcp_init().
* @return JSON array of tool definitions.
* Caller must free with hanzo_mcp_free_string().
* Returns NULL on error.
*/
char* hanzo_mcp_list_tools(HanzoMCPHandle handle);
/**
* Get the last error message (thread-local).
*
* @return Error string, or NULL if no error.
* Caller must free with hanzo_mcp_free_string().
*/
char* hanzo_mcp_last_error(void);
/**
* Free a string returned by hanzo_mcp_call_tool, hanzo_mcp_list_tools,
* hanzo_mcp_last_error, or hanzo_mcp_version.
*
* @param ptr String to free. NULL is safe (no-op).
*/
void hanzo_mcp_free_string(char* ptr);
/**
* Destroy the handle and release all resources.
*
* @param handle Handle to destroy. NULL is safe (no-op).
* Must not be called while hanzo_mcp_call_tool is in progress.
*/
void hanzo_mcp_destroy(HanzoMCPHandle handle);
/**
* Get version information as JSON.
*
* @return JSON string. Caller must free with hanzo_mcp_free_string().
*/
char* hanzo_mcp_version(void);
#ifdef __cplusplus
}
#endif
#endif /* HANZO_MCP_H */
+393
View File
@@ -0,0 +1,393 @@
//! C FFI for hanzo-mcp tools.
//!
//! Exposes the full HIP-0300 tool surface as a C-compatible shared/static library.
//! Designed for consumption via Go cgo, Python ctypes, or any C-compatible FFI.
//!
//! # Thread safety
//! All functions are safe to call from any OS thread. The internal tokio runtime
//! handles async scheduling. Multiple concurrent `hanzo_mcp_call_tool` calls are
//! supported — they block their respective calling threads while the tokio runtime
//! executes the tool asynchronously.
//!
//! # Memory ownership
//! - Strings returned by Rust (from `call_tool`, `list_tools`, `last_error`) must
//! be freed by the caller via `hanzo_mcp_free_string`.
//! - Strings passed to Rust (tool_name, params_json, config_json) are borrowed —
//! the caller retains ownership.
use std::cell::RefCell;
use std::ffi::{c_char, CStr, CString};
use std::ptr;
use crate::ToolRegistry;
/// Opaque handle wrapping a tokio runtime + tool registry.
struct FFIContext {
runtime: tokio::runtime::Runtime,
registry: ToolRegistry,
}
/// Opaque handle type exposed to C.
pub type HanzoMCPHandle = *mut FFIContext;
// Thread-local storage for the last error message.
thread_local! {
static LAST_ERROR: RefCell<Option<CString>> = RefCell::new(None);
}
fn set_last_error(msg: &str) {
LAST_ERROR.with(|cell| {
*cell.borrow_mut() = CString::new(msg).ok();
});
}
/// Convert a raw C string pointer to a Rust &str.
/// Returns None if the pointer is null or not valid UTF-8.
unsafe fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
if ptr.is_null() {
return None;
}
CStr::from_ptr(ptr).to_str().ok()
}
/// Return a heap-allocated C string. Caller must free with `hanzo_mcp_free_string`.
fn to_c_string(s: &str) -> *mut c_char {
match CString::new(s) {
Ok(cs) => cs.into_raw(),
Err(_) => {
// String contained an interior NUL — replace them
let sanitized = s.replace('\0', "\\0");
CString::new(sanitized)
.unwrap_or_else(|_| CString::new("internal error").unwrap())
.into_raw()
}
}
}
// ─── Public C API ──────────────────────────────────────────────────────────
/// Initialize the Hanzo MCP tool runtime.
///
/// Creates a multi-threaded tokio runtime and a `ToolRegistry` with all 13
/// HIP-0300 tools registered. The returned handle must be destroyed with
/// `hanzo_mcp_destroy`.
///
/// # Parameters
/// - `config_json`: Optional JSON configuration string (may be NULL for defaults).
///
/// # Returns
/// Opaque handle, or NULL on failure (call `hanzo_mcp_last_error` for details).
#[no_mangle]
pub extern "C" fn hanzo_mcp_init(config_json: *const c_char) -> HanzoMCPHandle {
let result = std::panic::catch_unwind(|| {
// Parse optional config
let _config: Option<serde_json::Value> = unsafe {
cstr_to_str(config_json).and_then(|s| serde_json::from_str(s).ok())
};
// Build tokio runtime
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("hanzo-mcp-ffi")
.build()
.map_err(|e| format!("Failed to create tokio runtime: {}", e))?;
// Build registry with all default tools
let registry = ToolRegistry::with_defaults();
let ctx = Box::new(FFIContext { runtime, registry });
Ok::<*mut FFIContext, String>(Box::into_raw(ctx))
});
match result {
Ok(Ok(ptr)) => ptr,
Ok(Err(msg)) => {
set_last_error(&msg);
ptr::null_mut()
}
Err(_) => {
set_last_error("panic during hanzo_mcp_init");
ptr::null_mut()
}
}
}
/// Call a tool by name with JSON parameters.
///
/// This function blocks the calling thread until the tool completes.
/// The internal tokio runtime handles async execution.
///
/// # Parameters
/// - `handle`: Handle from `hanzo_mcp_init`.
/// - `tool_name`: Tool name (e.g., "fs", "exec", "think").
/// - `params_json`: JSON object with tool parameters (e.g., `{"action":"read","path":"/tmp/foo"}`).
///
/// # Returns
/// JSON string with the result. Caller must free with `hanzo_mcp_free_string`.
/// Returns NULL on fatal error (call `hanzo_mcp_last_error`).
///
/// The result JSON has the shape:
/// ```json
/// {"success": true, "content": {...}, "error": null}
/// ```
#[no_mangle]
pub extern "C" fn hanzo_mcp_call_tool(
handle: HanzoMCPHandle,
tool_name: *const c_char,
params_json: *const c_char,
) -> *mut c_char {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if handle.is_null() {
return Err("null handle".to_string());
}
let ctx = unsafe { &*handle };
let name = unsafe { cstr_to_str(tool_name) }
.ok_or_else(|| "invalid tool_name".to_string())?;
let params_str = unsafe { cstr_to_str(params_json) }.unwrap_or("{}");
let params: serde_json::Value = serde_json::from_str(params_str)
.map_err(|e| format!("invalid params JSON: {}", e))?;
// Block on the async tool execution from the FFI thread.
// This is safe because cgo threads are plain OS threads, not tokio workers.
let tool_result = ctx
.runtime
.block_on(ctx.registry.execute(name, params))
.map_err(|e| format!("tool execution error: {}", e))?;
serde_json::to_string(&tool_result)
.map_err(|e| format!("serialization error: {}", e))
}));
match result {
Ok(Ok(json)) => to_c_string(&json),
Ok(Err(msg)) => {
// Return a structured error JSON instead of NULL for non-fatal errors
let err_json = serde_json::json!({
"success": false,
"content": null,
"error": msg
});
to_c_string(&err_json.to_string())
}
Err(_) => {
set_last_error("panic during hanzo_mcp_call_tool");
ptr::null_mut()
}
}
}
/// List all available tools.
///
/// # Returns
/// JSON array of tool definitions. Caller must free with `hanzo_mcp_free_string`.
/// Returns NULL on fatal error.
#[no_mangle]
pub extern "C" fn hanzo_mcp_list_tools(handle: HanzoMCPHandle) -> *mut c_char {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if handle.is_null() {
return Err("null handle".to_string());
}
let ctx = unsafe { &*handle };
let defs = ctx.registry.get_definitions();
serde_json::to_string(&defs).map_err(|e| format!("serialization error: {}", e))
}));
match result {
Ok(Ok(json)) => to_c_string(&json),
Ok(Err(msg)) => {
set_last_error(&msg);
ptr::null_mut()
}
Err(_) => {
set_last_error("panic during hanzo_mcp_list_tools");
ptr::null_mut()
}
}
}
/// Get the last error message (thread-local).
///
/// # Returns
/// Error string, or NULL if no error. Caller must free with `hanzo_mcp_free_string`.
#[no_mangle]
pub extern "C" fn hanzo_mcp_last_error() -> *mut c_char {
LAST_ERROR.with(|cell| {
let err = cell.borrow();
match err.as_ref() {
Some(cs) => {
// Clone the error so the caller owns it
match CString::new(cs.to_bytes()) {
Ok(clone) => clone.into_raw(),
Err(_) => ptr::null_mut(),
}
}
None => ptr::null_mut(),
}
})
}
/// Free a string previously returned by `hanzo_mcp_call_tool`, `hanzo_mcp_list_tools`,
/// or `hanzo_mcp_last_error`.
///
/// # Safety
/// The pointer must have been returned by one of the above functions and must not
/// have been freed already. Passing NULL is safe (no-op).
#[no_mangle]
pub extern "C" fn hanzo_mcp_free_string(ptr: *mut c_char) {
if !ptr.is_null() {
unsafe {
drop(CString::from_raw(ptr));
}
}
}
/// Destroy the handle and release all resources (shuts down tokio runtime).
///
/// # Safety
/// The handle must not be used after this call. Passing NULL is safe (no-op).
/// Must not be called while `hanzo_mcp_call_tool` is in progress on another thread.
#[no_mangle]
pub extern "C" fn hanzo_mcp_destroy(handle: HanzoMCPHandle) {
if !handle.is_null() {
unsafe {
drop(Box::from_raw(handle));
}
}
}
/// Get version information as a JSON string.
///
/// # Returns
/// JSON string. Caller must free with `hanzo_mcp_free_string`.
#[no_mangle]
pub extern "C" fn hanzo_mcp_version() -> *mut c_char {
let v = crate::version();
to_c_string(&v.to_string())
}
// ─── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
#[test]
fn test_init_and_destroy() {
let handle = hanzo_mcp_init(ptr::null());
assert!(!handle.is_null());
hanzo_mcp_destroy(handle);
}
#[test]
fn test_list_tools() {
let handle = hanzo_mcp_init(ptr::null());
assert!(!handle.is_null());
let result = hanzo_mcp_list_tools(handle);
assert!(!result.is_null());
let json_str = unsafe { CStr::from_ptr(result).to_str().unwrap() };
let tools: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
assert!(tools.len() >= 9); // At least the wired tools
hanzo_mcp_free_string(result);
hanzo_mcp_destroy(handle);
}
#[test]
fn test_call_tool_think() {
let handle = hanzo_mcp_init(ptr::null());
assert!(!handle.is_null());
let tool = CString::new("think").unwrap();
let params = CString::new(r#"{"action":"think","thought":"FFI test"}"#).unwrap();
let result = hanzo_mcp_call_tool(handle, tool.as_ptr(), params.as_ptr());
assert!(!result.is_null());
let json_str = unsafe { CStr::from_ptr(result).to_str().unwrap() };
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed["success"], true);
hanzo_mcp_free_string(result);
hanzo_mcp_destroy(handle);
}
#[test]
fn test_call_tool_fs_help() {
let handle = hanzo_mcp_init(ptr::null());
let tool = CString::new("fs").unwrap();
let params = CString::new(r#"{"action":"help"}"#).unwrap();
let result = hanzo_mcp_call_tool(handle, tool.as_ptr(), params.as_ptr());
assert!(!result.is_null());
let json_str = unsafe { CStr::from_ptr(result).to_str().unwrap() };
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed["success"], true);
hanzo_mcp_free_string(result);
hanzo_mcp_destroy(handle);
}
#[test]
fn test_null_handle() {
let tool = CString::new("think").unwrap();
let params = CString::new("{}").unwrap();
let result = hanzo_mcp_call_tool(ptr::null_mut(), tool.as_ptr(), params.as_ptr());
// Should return error JSON, not NULL
assert!(!result.is_null());
let json_str = unsafe { CStr::from_ptr(result).to_str().unwrap() };
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed["success"], false);
hanzo_mcp_free_string(result);
}
#[test]
fn test_unknown_tool() {
let handle = hanzo_mcp_init(ptr::null());
let tool = CString::new("nonexistent").unwrap();
let params = CString::new("{}").unwrap();
let result = hanzo_mcp_call_tool(handle, tool.as_ptr(), params.as_ptr());
assert!(!result.is_null());
let json_str = unsafe { CStr::from_ptr(result).to_str().unwrap() };
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed["success"], false);
hanzo_mcp_free_string(result);
hanzo_mcp_destroy(handle);
}
#[test]
fn test_version() {
let result = hanzo_mcp_version();
assert!(!result.is_null());
let json_str = unsafe { CStr::from_ptr(result).to_str().unwrap() };
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed["name"], "hanzo-mcp");
hanzo_mcp_free_string(result);
}
#[test]
fn test_free_null() {
// Should not crash
hanzo_mcp_free_string(ptr::null_mut());
}
#[test]
fn test_destroy_null() {
// Should not crash
hanzo_mcp_destroy(ptr::null_mut());
}
}
+56 -10
View File
@@ -12,6 +12,7 @@
/// - search: Unified code search
pub mod config;
pub mod ffi;
pub mod server;
pub mod protocol;
pub mod tools;
@@ -22,6 +23,7 @@ pub use server::MCPServer;
pub use tools::{
ExecTool, FsTool, PlanTool, ThinkTool, MemoryTool,
ComputerTool, BrowserTool, ModeTool,
CodeTool, GitTool, FetchTool, WorkspaceTool, TasksTool, HanzoTool,
list_tools, parity_status,
};
@@ -87,12 +89,18 @@ pub struct ToolRegistry {
tools: HashMap<String, Box<dyn MCPTool>>,
exec: Arc<RwLock<ExecTool>>,
fs: Arc<RwLock<FsTool>>,
code: Arc<RwLock<CodeTool>>,
git: Arc<RwLock<GitTool>>,
fetch: Arc<RwLock<FetchTool>>,
workspace: Arc<RwLock<WorkspaceTool>>,
plan: Arc<RwLock<PlanTool>>,
think: Arc<RwLock<ThinkTool>>,
memory: Arc<RwLock<MemoryTool>>,
computer: Arc<RwLock<ComputerTool>>,
browser: Arc<RwLock<BrowserTool>>,
mode: Arc<RwLock<ModeTool>>,
tasks: Arc<RwLock<TasksTool>>,
hanzo: Arc<RwLock<HanzoTool>>,
}
impl ToolRegistry {
@@ -101,12 +109,18 @@ impl ToolRegistry {
tools: HashMap::new(),
exec: Arc::new(RwLock::new(ExecTool::new())),
fs: Arc::new(RwLock::new(FsTool::new())),
code: Arc::new(RwLock::new(CodeTool::new())),
git: Arc::new(RwLock::new(GitTool::new())),
fetch: Arc::new(RwLock::new(FetchTool::new())),
workspace: Arc::new(RwLock::new(WorkspaceTool::new())),
plan: Arc::new(RwLock::new(PlanTool::new())),
think: Arc::new(RwLock::new(ThinkTool::new())),
memory: Arc::new(RwLock::new(MemoryTool::new())),
computer: Arc::new(RwLock::new(ComputerTool::new())),
browser: Arc::new(RwLock::new(BrowserTool::new())),
mode: Arc::new(RwLock::new(ModeTool::new())),
tasks: Arc::new(RwLock::new(TasksTool::new())),
hanzo: Arc::new(RwLock::new(HanzoTool::new())),
}
}
@@ -120,17 +134,13 @@ impl ToolRegistry {
pub fn list(&self) -> Vec<String> {
let mut names: Vec<String> = self.tools.keys().cloned().collect();
// Add built-in tools
// Add built-in tools (all 13 HIP-0300 canonical + search alias + browser extension)
names.extend(vec![
"exec".to_string(),
"fs".to_string(),
"search".to_string(),
"plan".to_string(),
"think".to_string(),
"memory".to_string(),
"computer".to_string(),
"browser".to_string(),
"mode".to_string(),
"exec".into(), "fs".into(), "code".into(), "git".into(),
"fetch".into(), "workspace".into(), "computer".into(),
"think".into(), "memory".into(), "hanzo".into(),
"plan".into(), "tasks".into(), "mode".into(),
"search".into(), "browser".into(),
]);
names.sort();
names.dedup();
@@ -189,6 +199,36 @@ impl ToolRegistry {
let result = self.mode.read().await.execute(args).await?;
Ok(ToolResult::ok(serde_json::from_str(&result)?))
}
"code" => {
let args: tools::CodeToolArgs = serde_json::from_value(params)?;
let result = self.code.read().await.execute(args).await?;
Ok(ToolResult::ok(result))
}
"git" => {
let args: tools::GitToolArgs = serde_json::from_value(params)?;
let result = self.git.read().await.execute(args).await?;
Ok(ToolResult::ok(result))
}
"fetch" => {
let args: tools::FetchToolArgs = serde_json::from_value(params)?;
let result = self.fetch.read().await.execute(args).await?;
Ok(ToolResult::ok(result))
}
"workspace" => {
let args: tools::WorkspaceToolArgs = serde_json::from_value(params)?;
let result = self.workspace.read().await.execute(args).await?;
Ok(ToolResult::ok(result))
}
"tasks" => {
let args: tools::TasksToolArgs = serde_json::from_value(params)?;
let result = self.tasks.read().await.execute(args).await?;
Ok(ToolResult::ok(result))
}
"hanzo" => {
let args: tools::HanzoToolArgs = serde_json::from_value(params)?;
let result = self.hanzo.read().await.execute(args).await?;
Ok(ToolResult::ok(result))
}
_ => {
if let Some(tool) = self.tools.get(name) {
tool.execute(params).await
@@ -247,6 +287,12 @@ impl ToolRegistry {
"description": tools::ModeToolDefinition::new().description,
"inputSchema": tools::ModeToolDefinition::new().input_schema
}),
tools::CodeToolDefinition::schema(),
tools::GitToolDefinition::schema(),
tools::FetchToolDefinition::schema(),
tools::WorkspaceToolDefinition::schema(),
tools::TasksToolDefinition::schema(),
tools::HanzoToolDefinition::schema(),
];
// Add custom registered tools
+1 -1
View File
@@ -12,7 +12,7 @@ interface Item { id: string; content: string; status: 'pending' | 'in_progress'
interface TodoList { items: Item[]; lastId: number; }
const todoPath = () => process.env.TODO_PATH || path.join(os.homedir(), '.hanzo', 'todos.json');
async function load(): Promise<TodoList> { try { return JSON.parse(await fs.readFile(todoPath(), 'utf-8')); } catch { return { items: [], lastId: 0 }; } }
async function load(): Promise<TodoList> { try { const data = JSON.parse(await fs.readFile(todoPath(), 'utf-8')); return { items: Array.isArray(data.items) ? data.items : [], lastId: typeof data.lastId === 'number' ? data.lastId : 0 }; } catch { return { items: [], lastId: 0 }; } }
async function save(t: TodoList) { await fs.mkdir(path.dirname(todoPath()), { recursive: true }); await fs.writeFile(todoPath(), JSON.stringify(t, null, 2)); }
export const tasksTool: Tool = {
+40 -2
View File
@@ -31,9 +31,11 @@ export const codeTool: Tool = {
inputSchema: {
type: 'object',
properties: {
action: { type: 'string', enum: ['search_symbol', 'outline', 'references', 'metrics', 'exports', 'types', 'hierarchy', 'rename', 'grep_replace'], description: 'Code action' },
action: { type: 'string', enum: ['search_symbol', 'outline', 'symbols', 'summarize', 'references', 'metrics', 'exports', 'types', 'hierarchy', 'rename', 'grep_replace'], description: 'Code action' },
query: { type: 'string', description: 'Symbol name or search query' },
uri: { type: 'string', description: 'File or directory path' },
path: { type: 'string', description: 'File path (alias for uri)' },
text: { type: 'string', description: 'Raw text input' },
scope: { type: 'string', description: 'Search scope (file, directory, project)', default: 'project' },
pattern: { type: 'string', description: 'File glob pattern' },
new_name: { type: 'string', description: 'New name for rename' },
@@ -45,7 +47,7 @@ export const codeTool: Tool = {
},
handler: async (args) => {
try {
const uri = args.uri || '.';
const uri = args.uri || args.path || '.';
switch (args.action) {
case 'search_symbol': {
@@ -90,6 +92,42 @@ export const codeTool: Tool = {
return envelope({ uri, symbols, imports, lines: lines.length }, 'outline');
}
case 'symbols': {
// Like outline but keyed on path param
const filePath = args.path || args.uri;
if (!filePath || filePath === '.') return fail('INVALID_PARAMS', 'path or uri required');
let content: string;
if (args.text) {
content = args.text;
} else {
content = await fs.readFile(filePath, 'utf-8');
}
const lines = content.split('\n');
const symbols: any[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
SYMBOL_RE.lastIndex = 0;
let m;
while ((m = SYMBOL_RE.exec(line)) !== null) {
const kind = line.match(/\b(class|interface|type|enum|function|const|struct|impl|fn|def)\b/)?.[1] || 'symbol';
symbols.push({ name: m[1], kind, line: i + 1 });
}
}
return envelope({ symbols, count: symbols.length, path: filePath }, 'symbols');
}
case 'summarize': {
const text = args.text || '';
const wordCount = text.split(/\s+/).filter(Boolean).length;
const lineCount = text.split('\n').length;
// Detect if it's a diff
const isDiff = text.includes('---') && text.includes('+++');
const summary = isDiff
? `Diff: ${lineCount} lines, ${text.split('\n').filter(l => l.startsWith('+')).length - 1} additions, ${text.split('\n').filter(l => l.startsWith('-')).length - 1} deletions`
: `Text: ${wordCount} words`;
return envelope({ summary, lines: lineCount, words: wordCount }, 'summarize');
}
case 'references': {
if (!args.query) return fail('INVALID_PARAMS', 'query (symbol name) required');
let hasRg = false;
+35 -1
View File
@@ -27,7 +27,7 @@ export const fetchTool: Tool = {
inputSchema: {
type: 'object',
properties: {
action: { type: 'string', enum: ['request', 'download', 'open'], description: 'Network action' },
action: { type: 'string', enum: ['request', 'fetch', 'head', 'download', 'open'], description: 'Network action' },
url: { type: 'string', description: 'URL' },
method: { type: 'string', description: 'HTTP method', default: 'GET' },
headers: { type: 'object', description: 'HTTP headers' },
@@ -62,6 +62,40 @@ export const fetchTool: Tool = {
}, 'request');
}
case 'fetch': {
const opts: RequestInit = {
method: args.method || 'GET',
headers: args.headers || {},
signal: AbortSignal.timeout(args.timeout || 30000),
};
if (args.body) opts.body = args.body;
const resp = await fetch(args.url, opts);
const contentType = resp.headers.get('content-type') || '';
let data: any;
if (contentType.includes('json')) {
data = await resp.json();
} else {
data = await resp.text();
}
return envelope({
text: typeof data === 'string' ? data.substring(0, 50000) : JSON.stringify(data),
status: resp.status,
headers: Object.fromEntries(resp.headers.entries()),
}, 'fetch');
}
case 'head': {
const resp = await fetch(args.url, {
method: 'HEAD',
headers: args.headers || {},
signal: AbortSignal.timeout(args.timeout || 30000),
});
return envelope({
status: resp.status,
headers: Object.fromEntries(resp.headers.entries()),
}, 'head');
}
case 'download': {
if (!args.output) return fail('INVALID_PARAMS', 'output path required');
const resp = await fetch(args.url, { signal: AbortSignal.timeout(args.timeout || 60000) });