Skip to content

Commit 896956e

Browse files
mcp fixes for deterministic usage
1 parent 768e8f2 commit 896956e

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

internal/mcp/server.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ import (
1818
"bufio"
1919
"context"
2020
"encoding/json"
21+
"errors"
2122
"fmt"
2223
"io"
24+
"net"
2325
"os"
26+
osexec "os/exec"
2427
"sync"
2528
"time"
29+
30+
"github.com/codag-megalith/codag-cli/internal/analytics"
31+
"github.com/codag-megalith/codag-cli/internal/api"
2632
)
2733

2834
// time aliases — kept local so the heartbeat code is easy to mock in tests
@@ -292,6 +298,11 @@ func (s *Server) handleToolCall(req jrpcRequest) {
292298
for {
293299
select {
294300
case r := <-done:
301+
// One analytics event per tool invocation, covering both the
302+
// success and error paths. This is the only place MCP (agent)
303+
// usage is observable — cobra-level events don't see individual
304+
// tool calls inside a long-lived `codag mcp` session.
305+
captureToolCall(p.Name, startTime, r.err)
295306
if r.err != nil {
296307
s.writeResult(req.ID, map[string]interface{}{
297308
"content": []map[string]interface{}{
@@ -316,6 +327,7 @@ func (s *Server) handleToolCall(req jrpcRequest) {
316327
// (Some clients ignore this response since they sent the cancel
317328
// themselves, but emitting a clean error keeps the protocol
318329
// state consistent and avoids the "hang on Calling codag…" UI.)
330+
captureToolCall(p.Name, startTime, context.Canceled)
319331
s.writeResult(req.ID, map[string]interface{}{
320332
"content": []map[string]interface{}{
321333
{"type": "text", "text": "tool call cancelled"},
@@ -327,6 +339,60 @@ func (s *Server) handleToolCall(req jrpcRequest) {
327339
}
328340
}
329341

342+
// captureToolCall records one mcp_tool_called analytics event per tool
343+
// invocation, mirroring the cli_command_completed/failed shape used for cobra
344+
// commands so MCP (agent) usage shows up alongside direct CLI usage in the same
345+
// PostHog project. analytics.Capture no-ops under CODAG_TELEMETRY opt-out, and
346+
// only error_code (never raw error text) is sent.
347+
func captureToolCall(name string, start time.Time, err error) {
348+
props := map[string]any{
349+
"tool": name,
350+
"duration_ms": int(timeSinceSeconds(start) * 1000),
351+
"ok": err == nil,
352+
}
353+
if err != nil {
354+
props["error_code"] = mcpErrorCode(err, props)
355+
}
356+
analytics.Capture("mcp_tool_called", props)
357+
}
358+
359+
// mcpErrorCode classifies an error into a stable, low-cardinality code and
360+
// stamps any safe structured detail onto props. Mirrors cmd.addTelemetryError;
361+
// duplicated here because internal/mcp cannot import the cmd package.
362+
func mcpErrorCode(err error, props map[string]any) string {
363+
var httpErr *api.HTTPError
364+
var billingErr *api.BillingError
365+
var exitErr *osexec.ExitError
366+
var netErr net.Error
367+
switch {
368+
case errors.Is(err, api.ErrUnauthenticated):
369+
return "unauthenticated"
370+
case errors.Is(err, api.ErrQuotaExceeded):
371+
return "quota_exceeded"
372+
case errors.As(err, &httpErr):
373+
props["http_status"] = httpErr.Status
374+
return "http_error"
375+
case errors.As(err, &billingErr):
376+
props["has_upgrade_path"] = billingErr.UpgradePath != ""
377+
return "billing_required"
378+
case errors.Is(err, api.ErrBillingRequired):
379+
return "billing_required"
380+
case errors.As(err, &exitErr):
381+
props["exit_code"] = exitErr.ExitCode()
382+
return "child_exit"
383+
case errors.Is(err, context.DeadlineExceeded):
384+
return "timeout"
385+
case errors.Is(err, context.Canceled):
386+
return "canceled"
387+
case errors.As(err, &netErr):
388+
if netErr.Timeout() {
389+
return "network_timeout"
390+
}
391+
return "network_error"
392+
}
393+
return "unknown"
394+
}
395+
330396
// notify writes an unsolicited JSON-RPC notification (no id field).
331397
func (s *Server) notify(method string, params any) {
332398
s.mu.Lock()

internal/mcp/tools_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package mcp
22

33
import (
4+
"context"
45
"encoding/json"
6+
"errors"
57
"net/http"
68
"net/http/httptest"
79
"strings"
810
"sync/atomic"
911
"testing"
12+
"time"
1013

1114
"github.com/codag-megalith/codag-cli/internal/api"
1215
)
@@ -102,6 +105,41 @@ func TestWrapAPIErrorBillingRequiredGivesAgentNextSteps(t *testing.T) {
102105
}
103106
}
104107

108+
func TestMCPErrorCodeClassifies(t *testing.T) {
109+
cases := []struct {
110+
err error
111+
want string
112+
}{
113+
{api.ErrUnauthenticated, "unauthenticated"},
114+
{api.ErrQuotaExceeded, "quota_exceeded"},
115+
{api.ErrBillingRequired, "billing_required"},
116+
{&api.HTTPError{Status: 500}, "http_error"},
117+
{&api.BillingError{UpgradePath: "/dashboard/billing"}, "billing_required"},
118+
{context.DeadlineExceeded, "timeout"},
119+
{context.Canceled, "canceled"},
120+
{errors.New("boom"), "unknown"},
121+
}
122+
for _, c := range cases {
123+
props := map[string]any{}
124+
if got := mcpErrorCode(c.err, props); got != c.want {
125+
t.Fatalf("mcpErrorCode(%v) = %q, want %q", c.err, got, c.want)
126+
}
127+
}
128+
// Structured detail is stamped onto props for the HTTP case.
129+
props := map[string]any{}
130+
mcpErrorCode(&api.HTTPError{Status: 503}, props)
131+
if props["http_status"] != 503 {
132+
t.Fatalf("http_status not stamped: %v", props["http_status"])
133+
}
134+
}
135+
136+
func TestCaptureToolCallNoPanicWhenTelemetryDisabled(t *testing.T) {
137+
// analytics client is uninitialized in tests, so Capture is a no-op; this
138+
// just guards against a nil-deref / panic regression in the chokepoint.
139+
captureToolCall("compact", time.Now(), nil)
140+
captureToolCall("tail_vercel", time.Now(), api.ErrUnauthenticated)
141+
}
142+
105143
func TestCompactRawTextUsesDeterministicFreeCompact(t *testing.T) {
106144
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
107145

0 commit comments

Comments
 (0)