mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
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:
+27
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,3 +86,4 @@ path = "src/main.rs"
|
||||
[lib]
|
||||
name = "hanzo_mcp"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user