Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions cli/internal/cli/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ var callCmd = &cobra.Command{
}

func runCall(cmd *cobra.Command, args []string) error {
// cmd may be callCmd invoked directly by the namespace dispatcher, whose
// context is never set by cobra. Fall back to a background context so the
// HTTP request builders never receive a nil context.
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}

// Strip any leading "--" sentinel cobra may inject.
if len(args) > 0 && args[0] == "--" {
args = args[1:]
Expand Down Expand Up @@ -52,7 +60,7 @@ func runCall(cmd *cobra.Command, args []string) error {

rest := args[1:]
if wantsHelp(rest) {
return runCallHelp(cmd.Context(), toolName)
return runCallHelp(ctx, toolName)
}

c, apiKey, _, err := resolveClient()
Expand All @@ -65,7 +73,7 @@ func runCall(cmd *cobra.Command, args []string) error {
os.Exit(exitUsage)
}

tool, err := c.GetTool(cmd.Context(), toolName)
tool, err := c.GetTool(ctx, toolName)
if err != nil {
var mcpErr *mcpclient.MCPError
if errors.As(err, &mcpErr) {
Expand All @@ -91,7 +99,7 @@ func runCall(cmd *cobra.Command, args []string) error {
os.Exit(exitUsage)
}

contents, err := c.CallTool(cmd.Context(), toolName, arguments)
contents, err := c.CallTool(ctx, toolName, arguments)
if err != nil {
var mcpErr *mcpclient.MCPError
if errors.As(err, &mcpErr) {
Expand Down
80 changes: 80 additions & 0 deletions cli/internal/cli/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cli

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/fullstorydev/subtext/cli/internal/config"
)

// TestNamespaceDispatchCallsTool drives the real namespace dispatcher
// (namespaceRunE -> callCmd) against a stub MCP server. This exercises the
// cmd.Context() path that the stubbed unit tests never reach: the namespace
// command invokes callCmd directly (never through cobra's Execute), so callCmd
// has no context of its own. Before the context fix this produced
// "net/http: nil Context" on every tool invocation. The test asserts the
// dispatcher completes a real tools/list + tools/call round trip.
func TestNamespaceDispatchCallsTool(t *testing.T) {
var sawListTools, sawCallTool bool

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
Method string `json:"method"`
}
_ = json.Unmarshal(body, &req)

w.Header().Set("Content-Type", "application/json")
switch req.Method {
case "tools/list":
sawListTools = true
// doc-list has no required arguments, so parseArgs succeeds with none.
io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{"tools":[`+
`{"name":"doc-list","description":"List docs","inputSchema":{"type":"object","properties":{},"required":[]}}`+
`]}}`)
case "tools/call":
sawCallTool = true
io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"dispatched-ok"}],"isError":false}}`)
default:
http.Error(w, "unexpected method "+req.Method, http.StatusBadRequest)
}
}))
t.Cleanup(srv.Close)

// Point the resolver at the stub. The stub is plain HTTP, so the endpoint
// validator needs the insecure escape hatch.
t.Setenv("SUBTEXT_ALLOW_INSECURE_ENDPOINT", "1")
saveGlobalFlags(t)
globalFlags.endpoint = srv.URL
globalFlags.apiKey = "test-key"
globalFlags.format = "json"
globalConfig = config.File{}

// Give the namespace command a context the way cobra's Execute would, then
// dispatch. The dispatcher must propagate this to callCmd or fall back to a
// non-nil context — either way the HTTP request must not see a nil context.
docCmd.SetContext(context.Background())

var err error
out := captureStdout(t, func() {
err = namespaceRunE(docCmd, []string{"list"})
})

if err != nil {
t.Fatalf("namespace dispatch returned error: %v", err)
}
if !sawListTools {
t.Error("server never received tools/list (GetTool path not exercised)")
}
if !sawCallTool {
t.Error("server never received tools/call (CallTool path not exercised)")
}
if !strings.Contains(out, "dispatched-ok") {
t.Errorf("expected tool output in stdout, got: %q", out)
}
}
4 changes: 4 additions & 0 deletions cli/internal/cli/namespaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ func namespaceRunE(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
toolName := cmd.Use + "-" + args[0]
// callCmd is invoked directly (never through cobra's Execute), so its
// context is never populated. Propagate the namespace command's context
// so the downstream HTTP calls in runCall don't receive a nil context.
callCmd.SetContext(cmd.Context())
return callCmd.RunE(callCmd, append([]string{toolName}, args[1:]...))
}

Expand Down