Skip to content
Merged
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
38 changes: 38 additions & 0 deletions .claude/MCPX.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,42 @@ mcpx daemon status # Show running daemons
2. Inspect: `mcpx <server> <tool> --help` to see flags
3. Call: `mcpx <server> <tool> --flag value`
4. For long args: `printf '{"key":"value"}' | mcpx <server> <tool> --stdin`

## Large Content: @file syntax

Any string flag accepts `@/path` to read from a file or `@-`/`-` to read from stdin:
```bash
mcpx <server> <tool> --body @/tmp/code.go # Read file into --body
mcpx <server> <tool> --body @- # Read stdin into --body
mcpx <server> <tool> --body - # Same (backward compat)
```

## Output Extraction: --pick

Extract a JSON field from the result without jq:
```bash
mcpx <server> <tool> --pick field.path # Dot-separated path
mcpx <server> <tool> --pick items.0.name # Array index access
```

## Timeout Override: --timeout

Override the default call timeout for a single invocation:
```bash
mcpx <server> <tool> --timeout 60s # Go duration format
```

## Stdin Merge

`--stdin` can be combined with CLI flags. Flags win on conflict:
```bash
echo '{"body":"content"}' | mcpx <server> <tool> --stdin --name_path Foo
```

## Tips for AI Agents

- Use `--body @/tmp/file` for large content to avoid shell escaping
- Use `--pick field` instead of piping through jq for single fields
- Combine `--stdin` with flags for mixed large+small arguments
- Use `--timeout 120s` for long-running operations
@SERENA.md
94 changes: 94 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cli

import (
"os"
"path/filepath"
"testing"

"github.com/codestz/mcpx/internal/mcp"
Expand Down Expand Up @@ -142,6 +144,33 @@ func TestParseToolFlags(t *testing.T) {
args: []string{"--config", `{"key":"value"}`},
want: map[string]any{"config": map[string]any{"key": "value"}},
},
{
name: "string flag with @file",
tool: mcp.Tool{
Name: "test",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.PropertySchema{
"body": {Type: "string", Description: "Body"},
},
},
},
args: []string{"--body", "@TESTFILE"}, // placeholder, overridden in test
want: map[string]any{"body": "file content here"},
},
}

// Set up temp file for the @file test.
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "testbody.txt")
if err := os.WriteFile(tmpFile, []byte("file content here\n"), 0644); err != nil {
t.Fatal(err)
}
// Patch the @file test case with the real path.
for i := range tests {
if tests[i].name == "string flag with @file" {
tests[i].args = []string{"--body", "@" + tmpFile}
}
}

for _, tt := range tests {
Expand Down Expand Up @@ -173,6 +202,71 @@ func TestParseToolFlags(t *testing.T) {
}
}

func TestResolveStringValue(t *testing.T) {
// Create a temp file for @file tests.
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "data.txt")
if err := os.WriteFile(tmpFile, []byte("hello from file\n"), 0644); err != nil {
t.Fatal(err)
}

tests := []struct {
name string
val string
want string
wantErr bool
}{
{"literal string", "hello", "hello", false},
{"@file reads file", "@" + tmpFile, "hello from file", false},
{"@nonexistent errors", "@/tmp/nonexistent_mcpx_test_file", "", true},
{"bare @ is literal", "@", "@", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveStringValue(tt.val, "test")
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}

func TestParseToolFlagsPartial(t *testing.T) {
tool := &mcp.Tool{
Name: "test",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.PropertySchema{
"name": {Type: "string", Description: "Name"},
"body": {Type: "string", Description: "Body"},
},
Required: []string{"name", "body"},
},
}

// parseToolFlagsPartial should not error on missing required flags.
got, err := parseToolFlagsPartial(tool, []string{"--name", "Foo"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["name"] != "Foo" {
t.Errorf("name: got %q, want %q", got["name"], "Foo")
}
if _, ok := got["body"]; ok {
t.Errorf("body should not be set")
}
}

func TestGlobalOpts_OutputMode(t *testing.T) {
tests := []struct {
name string
Expand Down
122 changes: 96 additions & 26 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ func buildServerCommand(name string, sc *config.ServerConfig, opts *globalOpts)
opts.quiet = true
case "--dry-run":
opts.dryRun = true
case "--pick":
if i+1 < len(args) {
i++
opts.pick = args[i]
}
case "--timeout":
if i+1 < len(args) {
i++
opts.timeout = args[i]
}
case "--help", "-h":
hasHelp = true
default:
Expand Down Expand Up @@ -180,13 +190,29 @@ func runTool(ctx context.Context, serverName string, sc *config.ServerConfig, to
return nil
}

// Parse arguments: either from stdin JSON or from flags.
// Parse arguments: either from stdin JSON (with optional flag merge) or from flags.
var toolArgs map[string]any
if useStdin {
toolArgs, err = parseStdinJSON()
if err != nil {
return fmt.Errorf("--stdin: %w", err)
}
// Merge CLI flags on top (flags win).
if len(filteredArgs) > 0 {
flagArgs, flagErr := parseToolFlagsPartial(tool, filteredArgs)
if flagErr != nil {
return enhanceParseError(flagErr, serverName, tool)
}
for k, v := range flagArgs {
toolArgs[k] = v
}
}
// Validate required fields against merged result.
for _, req := range tool.InputSchema.Required {
if _, ok := toolArgs[req]; !ok {
return fmt.Errorf("required field %q not provided (via --stdin or flags)", req)
}
}
} else {
toolArgs, err = parseToolFlags(tool, filteredArgs)
if err != nil {
Expand All @@ -203,14 +229,57 @@ func runTool(ctx context.Context, serverName string, sc *config.ServerConfig, to
return nil
}

result, err := client.CallTool(ctx, toolName, toolArgs)
// Apply per-call timeout if specified.
callCtx := ctx
if opts.timeout != "" {
d, err := time.ParseDuration(opts.timeout)
if err != nil {
return fmt.Errorf("--timeout: %w", err)
}
var cancel context.CancelFunc
callCtx, cancel = context.WithTimeout(ctx, d)
defer cancel()
}

result, err := client.CallTool(callCtx, toolName, toolArgs)
if err != nil {
return err
}

// Extract a specific field if --pick was specified.
if opts.pick != "" {
val, err := pickField(result, opts.pick)
if err != nil {
return fmt.Errorf("--pick %s: %w", opts.pick, err)
}
fmt.Fprintln(os.Stdout, val)
return nil
}

return out.printResult(result)
}

// resolveStringValue resolves @file / @- / - syntax for flag values.
// "-" or "@-" reads from stdin, "@<path>" reads a file, bare "@" is literal.
func resolveStringValue(val, flagName string) (string, error) {
if val == "-" || val == "@-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("read stdin for --%s: %w", flagName, err)
}
return strings.TrimRight(string(data), "\n"), nil
}
if len(val) > 1 && val[0] == '@' {
path := val[1:]
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read file for --%s: %w", flagName, err)
}
return strings.TrimRight(string(data), "\n"), nil
}
return val, nil
}

// parseStdinJSON reads a JSON object from stdin and returns it as tool arguments.
func parseStdinJSON() (map[string]any, error) {
data, err := io.ReadAll(os.Stdin)
Expand Down Expand Up @@ -255,7 +324,18 @@ func enhanceParseError(err error, serverName string, tool *mcp.Tool) error {
}

// parseToolFlags builds flags from a tool's JSON schema and parses rawArgs.
// Required flags are enforced.
func parseToolFlags(tool *mcp.Tool, rawArgs []string) (map[string]any, error) {
return parseToolFlagsInternal(tool, rawArgs, false)
}

// parseToolFlagsPartial is like parseToolFlags but does not enforce required flags.
// Used when merging CLI flags on top of --stdin JSON.
func parseToolFlagsPartial(tool *mcp.Tool, rawArgs []string) (map[string]any, error) {
return parseToolFlagsInternal(tool, rawArgs, true)
}

func parseToolFlagsInternal(tool *mcp.Tool, rawArgs []string, skipRequired bool) (map[string]any, error) {
tmpCmd := &cobra.Command{
Use: tool.Name,
SilenceUsage: true,
Expand Down Expand Up @@ -306,9 +386,11 @@ func parseToolFlags(tool *mcp.Tool, rawArgs []string) (map[string]any, error) {
}
}

for _, req := range tool.InputSchema.Required {
if _, ok := flags[req]; ok {
tmpCmd.MarkFlagRequired(req)
if !skipRequired {
for _, req := range tool.InputSchema.Required {
if _, ok := flags[req]; ok {
tmpCmd.MarkFlagRequired(req)
}
}
}

Expand All @@ -325,13 +407,9 @@ func parseToolFlags(tool *mcp.Tool, rawArgs []string) (map[string]any, error) {

switch fv.kind {
case "string":
val := *fv.strVal
if val == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("read stdin for --%s: %w", name, err)
}
val = strings.TrimRight(string(data), "\n")
val, err := resolveStringValue(*fv.strVal, name)
if err != nil {
return nil, err
}
result[name] = val
case "integer":
Expand All @@ -341,13 +419,9 @@ func parseToolFlags(tool *mcp.Tool, rawArgs []string) (map[string]any, error) {
case "boolean":
result[name] = *fv.boolVal
case "array":
val := *fv.strVal
if val == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("read stdin for --%s: %w", name, err)
}
val = strings.TrimRight(string(data), "\n")
val, err := resolveStringValue(*fv.strVal, name)
if err != nil {
return nil, err
}
var arr []any
if err := json.Unmarshal([]byte(val), &arr); err != nil {
Expand All @@ -360,13 +434,9 @@ func parseToolFlags(tool *mcp.Tool, rawArgs []string) (map[string]any, error) {
}
result[name] = arr
case "object":
val := *fv.strVal
if val == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("read stdin for --%s: %w", name, err)
}
val = strings.TrimRight(string(data), "\n")
val, err := resolveStringValue(*fv.strVal, name)
if err != nil {
return nil, err
}
var obj map[string]any
if err := json.Unmarshal([]byte(val), &obj); err != nil {
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,39 @@ func generateMCPXMD(cfg *config.Config) string {
b.WriteString("3. Call: `mcpx <server> <tool> --flag value`\n")
b.WriteString("4. For long args: `printf '{\"key\":\"value\"}' | mcpx <server> <tool> --stdin`\n")

b.WriteString("\n## Large Content: @file syntax\n\n")
b.WriteString("Any string flag accepts `@/path` to read from a file or `@-`/`-` to read from stdin:\n")
b.WriteString("```bash\n")
b.WriteString("mcpx <server> <tool> --body @/tmp/code.go # Read file into --body\n")
b.WriteString("mcpx <server> <tool> --body @- # Read stdin into --body\n")
b.WriteString("mcpx <server> <tool> --body - # Same (backward compat)\n")
b.WriteString("```\n")

b.WriteString("\n## Output Extraction: --pick\n\n")
b.WriteString("Extract a JSON field from the result without jq:\n")
b.WriteString("```bash\n")
b.WriteString("mcpx <server> <tool> --pick field.path # Dot-separated path\n")
b.WriteString("mcpx <server> <tool> --pick items.0.name # Array index access\n")
b.WriteString("```\n")

b.WriteString("\n## Timeout Override: --timeout\n\n")
b.WriteString("Override the default call timeout for a single invocation:\n")
b.WriteString("```bash\n")
b.WriteString("mcpx <server> <tool> --timeout 60s # Go duration format\n")
b.WriteString("```\n")

b.WriteString("\n## Stdin Merge\n\n")
b.WriteString("`--stdin` can be combined with CLI flags. Flags win on conflict:\n")
b.WriteString("```bash\n")
b.WriteString("echo '{\"body\":\"content\"}' | mcpx <server> <tool> --stdin --name_path Foo\n")
b.WriteString("```\n")

b.WriteString("\n## Tips for AI Agents\n\n")
b.WriteString("- Use `--body @/tmp/file` for large content to avoid shell escaping\n")
b.WriteString("- Use `--pick field` instead of piping through jq for single fields\n")
b.WriteString("- Combine `--stdin` with flags for mixed large+small arguments\n")
b.WriteString("- Use `--timeout 120s` for long-running operations\n")

return b.String()
}

Expand Down
Loading
Loading