package msglib import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "strings" "sync/atomic" "time" ) const ( Protocol = "isphere.msglib.v1" DefaultPassword = "123" ) var requestCounter uint64 // Config describes the local sidecar process and copied MsgLib DB inputs. type Config struct { SidecarPath string SidecarArgs []string SQLiteDLLPath string DBPath string Password string Timeout time.Duration ExtraEnv []string } // ConfigFromEnv reads the C28 environment shape without wiring it into MCP tools. func ConfigFromEnv() (Config, error) { cfg := Config{ SidecarPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_SIDECAR_EXE")), SQLiteDLLPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_SQLITE_DLL")), DBPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_DB")), Password: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_PASSWORD")), } if cfg.Password == "" { cfg.Password = DefaultPassword } if cfg.SidecarPath == "" { return cfg, errors.New("ISPHERE_MSGLIB_SIDECAR_EXE is not configured") } return cfg, nil } // NewClient returns a process-backed MsgLib sidecar client. func NewClient(cfg Config) *Client { if cfg.Password == "" { cfg.Password = DefaultPassword } if cfg.Timeout == 0 { cfg.Timeout = 10 * time.Second } return &Client{config: cfg} } type Client struct { config Config } type SelfCheckResult struct { SidecarName string `json:"sidecar_name"` SidecarVersion string `json:"sidecar_version"` Protocol string `json:"protocol"` Runtime string `json:"runtime"` ProcessBits int `json:"process_bits"` ReadOnly bool `json:"read_only"` MutatesDB bool `json:"mutates_db"` } type DisplaySource struct { Source string `json:"source"` Table string `json:"table"` Available bool `json:"available"` RequiredColumns []string `json:"required_columns"` PresentColumns []string `json:"present_columns"` } type SidecarError struct { Code string `json:"code"` Message string `json:"message"` } func (e *SidecarError) Error() string { if e == nil { return "msglib sidecar error" } if e.Code == "" { return "msglib sidecar error: " + e.Message } if e.Message == "" { return "msglib sidecar error: " + e.Code } return fmt.Sprintf("msglib sidecar error %s: %s", e.Code, e.Message) } func (c *Client) SelfCheck(ctx context.Context) (SelfCheckResult, error) { var out SelfCheckResult err := c.call(ctx, "self_check", map[string]any{}, &out) return out, err } func (c *Client) DisplaySources(ctx context.Context) ([]DisplaySource, error) { args := map[string]any{ "sqlite_dll_path": c.config.SQLiteDLLPath, "db_path": c.config.DBPath, "password": c.config.Password, } var out displaySourcesData if err := c.call(ctx, "display_sources", args, &out); err != nil { return nil, err } return out.DisplaySources, nil } type displaySourcesData struct { DisplaySources []DisplaySource `json:"display_sources"` } type requestEnvelope struct { Protocol string `json:"protocol"` RequestID string `json:"request_id"` Op string `json:"op"` Args map[string]any `json:"args,omitempty"` } type responseEnvelope struct { Protocol string `json:"protocol"` RequestID string `json:"request_id"` Op string `json:"op"` OK bool `json:"ok"` Data json.RawMessage `json:"data"` Error *SidecarError `json:"error"` Warnings []string `json:"warnings"` } func (c *Client) call(ctx context.Context, op string, args map[string]any, out any) error { if ctx == nil { ctx = context.Background() } if strings.TrimSpace(c.config.SidecarPath) == "" { return errors.New("msglib sidecar path is not configured") } requestID := nextRequestID() req := requestEnvelope{ Protocol: Protocol, RequestID: requestID, Op: op, Args: args, } payload, err := json.Marshal(req) if err != nil { return fmt.Errorf("marshal msglib sidecar request: %w", err) } payload = append(payload, '\n') callCtx := ctx var cancel context.CancelFunc if c.config.Timeout > 0 { callCtx, cancel = context.WithTimeout(ctx, c.config.Timeout) defer cancel() } cmd := exec.CommandContext(callCtx, c.config.SidecarPath, c.config.SidecarArgs...) cmd.Stdin = bytes.NewReader(payload) var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if len(c.config.ExtraEnv) > 0 { cmd.Env = append(os.Environ(), c.config.ExtraEnv...) } runErr := cmd.Run() if callCtx.Err() != nil { if errors.Is(callCtx.Err(), context.DeadlineExceeded) { return fmt.Errorf("msglib sidecar %s timed out: %w", op, context.DeadlineExceeded) } return fmt.Errorf("msglib sidecar %s context failed: %w", op, callCtx.Err()) } if runErr != nil { stderrText := strings.TrimSpace(stderr.String()) if stderrText == "" { return fmt.Errorf("msglib sidecar %s process failed: %w", op, runErr) } return fmt.Errorf("msglib sidecar %s process failed: %w: %s", op, runErr, stderrText) } body := bytes.TrimSpace(stdout.Bytes()) if len(body) == 0 { return fmt.Errorf("msglib sidecar %s returned empty stdout", op) } var resp responseEnvelope if err := json.Unmarshal(body, &resp); err != nil { return fmt.Errorf("decode msglib sidecar %s response: %w", op, err) } if resp.Protocol != Protocol { return fmt.Errorf("msglib sidecar %s response protocol = %q, want %q", op, resp.Protocol, Protocol) } if resp.RequestID != requestID { return fmt.Errorf("msglib sidecar %s response request_id = %q, want %q", op, resp.RequestID, requestID) } if resp.Op != op { return fmt.Errorf("msglib sidecar response op = %q, want %q", resp.Op, op) } if !resp.OK { if resp.Error != nil { return fmt.Errorf("msglib sidecar %s failed: %w", op, resp.Error) } return fmt.Errorf("msglib sidecar %s failed", op) } if out == nil || len(resp.Data) == 0 || bytes.Equal(resp.Data, []byte("null")) { return nil } if err := json.Unmarshal(resp.Data, out); err != nil { return fmt.Errorf("decode msglib sidecar %s data: %w", op, err) } return nil } func nextRequestID() string { n := atomic.AddUint64(&requestCounter, 1) return fmt.Sprintf("go-%d-%d", time.Now().UnixNano(), n) }