diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0596b29..942fe9a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ksrc", "description": "Ksrc CLI skill for searching/reading Kotlin dependency sources", - "version": "0.5.2", + "version": "0.6.0", "repository": "https://github.com/respawn-app/ksrc" } diff --git a/.github/workflows/auto-assign-issues.yml b/.github/workflows/auto-assign-issues.yml new file mode 100644 index 0000000..8e56c4c --- /dev/null +++ b/.github/workflows/auto-assign-issues.yml @@ -0,0 +1,20 @@ +name: Auto-assign issues + +on: + issues: + types: [opened, reopened] + +permissions: + issues: write + +jobs: + auto-assign: + runs-on: ubuntu-latest + steps: + - name: Auto-assign issue + uses: pozil/auto-assign-issue@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + assignees: Nek-12 + numOfAssignee: 1 + allowSelfAssign: true diff --git a/AGENTS.md b/AGENTS.md index 126b9da..a742792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ - Search changes: must keep `rg` call scoped to resolved JARs only. - After code changes, rebuild the binary to `./bin/ksrc` so the symlinked CLI updates for the user. - Brew manipulation: The brew tap with the ksrc formulat is separate repo at https://github.com/respawn-app/homebrew-tap . It's usually cloned at the parent dir of the cwd (./../homebrew-tap/) +- MCP server: `ksrc mcp` (stdio). Default tools: `search`, `cat`, `deps`. Enable extras via `--tools=` or `--tools=all`. ## Tests - Unit: parsing, version selection, file‑id handling. @@ -42,4 +43,5 @@ ## Clean Merge Expectations - Keep changes focused; - Update ./docs and ./skills when CLI flags, outputs, APIs or formats change. +- Release process: see ./docs/release-workflow.md - Update AGENTS.md (this file) with learnings/rules/memories for future you. diff --git a/README.md b/README.md index c42af4f..186ae83 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,28 @@ Install from the public GitHub path: $skill-installer install https://github.com/respawn-app/ksrc/tree/main/skills/ksrc ``` +### MCP (Model Context Protocol) + +Use when your agent doesn't have `bash` tool. Configure your MCP client to spawn the stdio server: + +```json +{ + "mcpServers": { + "ksrc": { + "command": "ksrc", + "args": ["mcp"] + } + } +} +``` + +Default tools: `search`, `cat`, `deps`. Enable more via `--tools=` (e.g., `--tools=search,cat,deps,resolve` or `--tools=all`). + +You shouldn't need the skill if you use mcp, but if your agent has access to `bash` tool, prefer CLI+bash instead of the mcp. + ### AGENTS.md prompt -> Use `ksrc` bash command to discover Kotlin/gradle library dependency sources. Start with `ksrc --help`. +> Avoid directly accessing `.gradle`; instead, proactively use `ksrc` bash tool to inspect source code of dependencies to learn API shapes or implementations. Start with `ksrc --help`. ## Usage diff --git a/VERSION b/VERSION index cb0c939..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.2 +0.6.0 diff --git a/docs/cli-api.md b/docs/cli-api.md index f29a35e..76f64f4 100644 --- a/docs/cli-api.md +++ b/docs/cli-api.md @@ -190,6 +190,33 @@ Diagnostics for project detection, Gradle cache accessibility, and source availa --- +### `ksrc mcp` +Run an MCP server over stdio for tool integrations. + +**Usage** +``` +ksrc mcp [flags] +``` + +**Flags** +- `--tools `: Comma-separated tool list (default: `search,cat,deps`; use `all` for all tools) + +**Default tools** +- `search` +- `cat` +- `deps` + +**Optional tools (enable via --tools)** +- `fetch` +- `resolve` +- `where` + +**Notes** +- Transport is stdio only; clients should spawn `ksrc mcp` via `mcp.json`. +- Outputs are plain text matching CLI formats. + +--- + ## File Identifier `` is a fully qualified path to a file inside a source JAR: `group:artifact:version!/path/inside/jar.kt` diff --git a/docs/decisions.md b/docs/decisions.md index 335e39e..0f4143e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -60,3 +60,8 @@ Rationale: avoid expensive Gradle runs unless needed; prioritize the most likely - CLI delegates resolution to `internal/resolution` to keep command wiring thin. - Gradle traversal is separated from invocation/parsing with an injectable resolver for tests. - Search strategy selection is separated from rg output parsing; zip capability is cached per process. + +## 2026-01-31: MCP tools return plaintext only (no structuredContent) +- Problem: some MCP harnesses (e.g. Codex) prefer `structuredContent` over `content`. The Go SDK auto-populates `structuredContent` for typed handlers (`ToolHandlerFor`), and when the output type was `struct{}`, it serialized to `{}`. That caused harnesses to ignore the real plaintext output in `content`. +- Decision: switch MCP tools to untyped handlers (`ToolHandler`) and supply explicit `InputSchema` to keep validation while ensuring we only emit `content` text. Errors are returned as `IsError=true` with a text payload in `content`. +- Tradeoff: we lose auto-generated output schemas/structured output, but avoid empty `{}` structured payloads and keep cross-harness behavior consistent for plaintext tools. diff --git a/docs/release-workflow.md b/docs/release-workflow.md new file mode 100644 index 0000000..b448fe2 --- /dev/null +++ b/docs/release-workflow.md @@ -0,0 +1,59 @@ +# Release workflow + +This document describes how a release is cut, what must be bumped, and what automation runs. + +## Files and locations + +- Version source: `VERSION` +- Claude plugin version: `.claude-plugin/plugin.json` +- Release workflow: `.github/workflows/release.yml` +- Tap update script: `scripts/update-brew-tap.sh` +- Homebrew tap repo (separate): `../homebrew-tap` or https://github.com/respawn-app/homebrew-tap. + - Formula: `../homebrew-tap/Formula/ksrc.rb` + - Tap CI: + - `../homebrew-tap/.github/workflows/tests.yml` (brew test-bot) + - `../homebrew-tap/.github/workflows/publish.yml` (brew pr-pull) + +## What to bump before a release + +1. `VERSION` + - This becomes the tag `vX.Y.Z` and the CLI version. +2. `.claude-plugin/plugin.json` + - Keep plugin version aligned with the release version. + +Optional (as needed): +- Update docs in `docs/` and skills in `skills/` when CLI flags, outputs, APIs or formats change. + +## How a release is cut + +Trigger the workflow: +- `gh workflow run release.yml --ref main` + +What it does: +1. Reads `VERSION` and computes the tag `vX.Y.Z`. +2. Creates the git tag (if missing) and pushes it. +3. Builds release binaries for all OS/arch pairs and uploads them to a draft GitHub release. +4. Updates the Homebrew tap by opening a PR in `respawn-app/homebrew-tap`: + - `scripts/update-brew-tap.sh` updates the source tarball URL + sha256. + - The script also removes any existing `bottle do` block, so bottles are regenerated. + +## Tap publishing (bottles) + +Bottles are generated and uploaded by the tap repo: + +1. The release workflow opens a tap PR with label `pr-pull`. +2. `../homebrew-tap/.github/workflows/publish.yml` runs `brew pr-pull` on that PR. +3. `brew pr-pull` builds bottles and writes the `bottle do` block back into the formula. +4. The PR is merged, and users can install via bottles by default. + +Notes: +- `brew install -s ksrc` still builds from source because the formula points at the source tarball. +- If bottles become mismatched with the formula (version/revision/license), re-run pr-pull on a fresh PR with no bottle block. + +## Post-release checks + +- Verify the draft release assets are present and correct. +- Verify the tap PR is opened and `pr-pull` ran successfully. +- On macOS 26 Tahoe (arm64), run: + - `brew tap respawn-app/tap` + - `brew install ksrc` diff --git a/go.mod b/go.mod index 22b0bf5..3fb23fe 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,15 @@ module github.com/respawn-app/ksrc go 1.25.5 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/modelcontextprotocol/go-sdk v1.1.0 + github.com/spf13/cobra v1.10.2 +) require ( + github.com/google/jsonschema-go v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..4d3d563 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,22 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 559a37a..bcf1d2f 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -6,6 +6,7 @@ import ( "bytes" "fmt" "io" + "regexp" "strings" ) @@ -19,15 +20,15 @@ func ParseLineRange(value string) (*LineRange, error) { if value == "" { return nil, nil } - parts := strings.Split(value, ",") - if len(parts) != 2 { + matches := lineRangeRe.FindStringSubmatch(value) + if matches == nil { return nil, fmt.Errorf("invalid line range: %q", value) } - start, err := parsePositive(parts[0]) + start, err := parsePositive(matches[1]) if err != nil { return nil, err } - end, err := parsePositive(parts[1]) + end, err := parsePositive(matches[2]) if err != nil { return nil, err } @@ -46,6 +47,8 @@ func parsePositive(s string) (int, error) { return n, nil } +var lineRangeRe = regexp.MustCompile(`^\s*(\d+)\s*(?:,|:|-|\.{2}|;|\s)\s*(\d+)\s*$`) + // ReadFileFromZip reads a file from a zip/jar and optionally slices by line range. func ReadFileFromZip(zipPath, innerPath string, lr *LineRange) ([]byte, error) { zr, err := zip.OpenReader(zipPath) diff --git a/internal/cat/cat_test.go b/internal/cat/cat_test.go index 63e9eec..46baa24 100644 --- a/internal/cat/cat_test.go +++ b/internal/cat/cat_test.go @@ -8,14 +8,28 @@ import ( ) func TestParseLineRange(t *testing.T) { - lr, err := ParseLineRange("1,3") - if err != nil { - t.Fatalf("unexpected error: %v", err) + cases := []struct { + in string + start int + end int + }{ + {"1,3", 1, 3}, + {"1:3", 1, 3}, + {"1-3", 1, 3}, + {"1 3", 1, 3}, + {"1..3", 1, 3}, + {"1;3", 1, 3}, } - if lr.Start != 1 || lr.End != 3 { - t.Fatalf("unexpected range: %+v", lr) + for _, tc := range cases { + lr, err := ParseLineRange(tc.in) + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.in, err) + } + if lr.Start != tc.start || lr.End != tc.end { + t.Fatalf("unexpected range for %q: %+v", tc.in, lr) + } } - if _, err := ParseLineRange("1:3"); err == nil { + if _, err := ParseLineRange("1 3 4"); err == nil { t.Fatal("expected error for invalid range") } } diff --git a/internal/cli/cat.go b/internal/cli/cat.go index 4e7e083..78acb65 100644 --- a/internal/cli/cat.go +++ b/internal/cli/cat.go @@ -91,7 +91,7 @@ func newCatCmd(app *App) *cobra.Command { cmd.Flags().BoolVar(&flags.IncludeBuildSrc, "buildsrc", true, "include buildSrc dependencies (set --buildsrc=false to disable)") cmd.Flags().BoolVar(&flags.IncludeBuildscript, "buildscript", true, "include buildscript classpath dependencies (set --buildscript=false to disable)") cmd.Flags().BoolVar(&flags.IncludeIncludedBuilds, "include-builds", true, "include composite builds (includeBuild) (set --include-builds=false to disable)") - cmd.Flags().StringVar(&lines, "lines", "", "line range (start,end)") + cmd.Flags().StringVar(&lines, "lines", "", "line range (start,end | start:end | start-end | start..end | start;end)") return cmd } @@ -108,8 +108,7 @@ func findJarByCoord(sources []resolve.SourceJar, coord resolve.Coord) (string, e func findFileInJars(sources []resolve.SourceJar, inner string) (string, string, error) { inner = strings.TrimPrefix(inner, "/") for _, s := range sources { - data, err := cat.ReadFileFromZip(s.Path, inner, nil) - if err == nil && len(data) > 0 { + if _, err := cat.ReadFileFromZip(s.Path, inner, nil); err == nil { return s.Path, inner, nil } } diff --git a/internal/cli/cat_empty_test.go b/internal/cli/cat_empty_test.go new file mode 100644 index 0000000..5a8464e --- /dev/null +++ b/internal/cli/cat_empty_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/respawn-app/ksrc/internal/resolve" +) + +func TestFindFileInJarsAllowsEmptyFile(t *testing.T) { + jarPath := filepath.Join(t.TempDir(), "empty.jar") + inner := "com/example/Empty.kt" + if err := writeZipFile(jarPath, inner, ""); err != nil { + t.Fatalf("write zip: %v", err) + } + + sources := []resolve.SourceJar{{ + Coord: resolve.Coord{Group: "com.example", Artifact: "demo", Version: "1.0.0"}, + Path: jarPath, + }} + + path, foundInner, err := findFileInJars(sources, inner) + if err != nil { + t.Fatalf("findFileInJars error: %v", err) + } + if path != jarPath { + t.Fatalf("unexpected jar path: %s", path) + } + if foundInner != inner { + t.Fatalf("unexpected inner path: %s", foundInner) + } +} + +func writeZipFile(path, inner, content string) error { + f, err := os.Create(path) + if err != nil { + return err + } + zw := zip.NewWriter(f) + w, err := zw.Create(inner) + if err != nil { + _ = zw.Close() + _ = f.Close() + return err + } + if _, err := w.Write([]byte(content)); err != nil { + _ = zw.Close() + _ = f.Close() + return err + } + if err := zw.Close(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go new file mode 100644 index 0000000..fad17a2 --- /dev/null +++ b/internal/cli/mcp.go @@ -0,0 +1,32 @@ +package cli + +import ( + "context" + + "github.com/respawn-app/ksrc/internal/mcpserver" + "github.com/spf13/cobra" +) + +func newMcpCmd(app *App) *cobra.Command { + var tools string + + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run MCP server over stdio", + RunE: func(cmd *cobra.Command, args []string) error { + set, err := mcpserver.ParseTools(tools) + if err != nil { + return err + } + return mcpserver.Run(context.Background(), mcpserver.Options{ + Runner: app.Runner, + Verbose: app.Verbose, + Tools: set, + Version: versionString(), + }) + }, + } + + cmd.Flags().StringVar(&tools, "tools", "", "comma-separated tool list (default: search,cat,deps; use 'all' for all tools)") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f0131d5..721e97a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -36,6 +36,7 @@ func NewRootCommand(app *App) *cobra.Command { cmd.AddCommand(newFetchCmd(app)) cmd.AddCommand(newWhereCmd(app)) cmd.AddCommand(newDoctorCmd(app)) + cmd.AddCommand(newMcpCmd(app)) return cmd } diff --git a/internal/mcpserver/handlers.go b/internal/mcpserver/handlers.go new file mode 100644 index 0000000..2cb8933 --- /dev/null +++ b/internal/mcpserver/handlers.go @@ -0,0 +1,594 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/respawn-app/ksrc/internal/cat" + "github.com/respawn-app/ksrc/internal/executil" + "github.com/respawn-app/ksrc/internal/resolution" + "github.com/respawn-app/ksrc/internal/resolve" + "github.com/respawn-app/ksrc/internal/search" +) + +type toolState struct { + runner executil.Runner + verbose bool +} + +func registerTools(server *mcp.Server, state *toolState, tools ToolSet) { + if tools.Enabled(ToolSearch) { + server.AddTool(&mcp.Tool{ + Name: toolName(ToolSearch), + Description: "Avoid directly accessing `.gradle`; Instead, proactively use this tool to find third-party Gradle dependency sources & learn unfamiliar APIs. Start by calling `search` (this tool) and pass `query` (rg-style globs) to find matches. This returns file-id and the match: `group:artifact:version!/path/inside/jar.kt line:col: . Then pass returned file-id to the `cat` tool to read the file content", + InputSchema: mustInputSchema[SearchInput](), + }, state.handleSearch) + } + if tools.Enabled(ToolCat) { + server.AddTool(&mcp.Tool{ + Name: toolName(ToolCat), + Description: "Read a file by file-id returned from ksrc `search` tool. Recommended: pass `lines` range as \"A,B\" to avoid reading large files.", + InputSchema: mustInputSchema[CatInput](), + }, state.handleCat) + } + if tools.Enabled(ToolDeps) { + server.AddTool(&mcp.Tool{ + Name: toolName(ToolDeps), + Description: "List resolved dependencies and whether their sources are available. Use when no matches or sources are found unexpectedly. By default, search already download deps, but you may need to use/ ask the user to enable `fetch` tool if you need to fetch a dependency that your project does not depend on.", + InputSchema: mustInputSchema[DepsInput](), + }, state.handleDeps) + } + if tools.Enabled(ToolFetch) { + server.AddTool(&mcp.Tool{ + Name: toolName(ToolFetch), + Description: "Ensure sources for a coordinate exist in Gradle caches. You may need to call this if current `project` (by default the cwd) doesn't directly use the target dependency, e.g. composite builds or multiple subprojects", + InputSchema: mustInputSchema[FetchInput](), + }, state.handleFetch) + } + if tools.Enabled(ToolResolve) { + server.AddTool(&mcp.Tool{ + Name: toolName(ToolResolve), + Description: "Resolve dependency source jars. Use this to list all source jars that may contain needed dependency, or when diagnosing missing sources.", + InputSchema: mustInputSchema[ResolveInput](), + }, state.handleResolve) + } + if tools.Enabled(ToolWhere) { + server.AddTool(&mcp.Tool{ + Name: toolName(ToolWhere), + Description: "Locate cached source artifact or file and return its full path. Use when diagnosing missing sources or when you want to manually operate on the source file.", + InputSchema: mustInputSchema[WhereInput](), + }, state.handleWhere) + } +} + +func toolName(name string) string { + return name +} + +type SearchInput struct { + Query string `json:"query" jsonschema:"search pattern as a rg-style glob. required."` + Context int `json:"context,omitempty" jsonschema:"context lines (optional, default: 0)"` + Group string `json:"group,omitempty" jsonschema:"group filter (optional, default: all dependencies)"` + Artifact string `json:"artifact,omitempty" jsonschema:"artifact filter (optional, default: all artifacts)"` + Version string `json:"version,omitempty" jsonschema:"version filter (optional, default: all versions)"` + Config []string `json:"config,omitempty" jsonschema:"Gradle config filters (optional, default: scope defaults)"` + Project string `json:"project,omitempty" jsonschema:"project path (optional, default: . (cwd))"` + Subprojects []string `json:"subprojects,omitempty" jsonschema:"subproject filters (optional, default: all subprojects)"` + RgArgs []string `json:"rgArgs,omitempty" jsonschema:"extra rg args (optional, default: none)"` + Scope string `json:"scope,omitempty" jsonschema:"dependency scope (optional, default: compile)"` + Targets []string `json:"targets,omitempty" jsonschema:"KMP target filters (optional, default: all targets)"` +} + +type CatInput struct { + FileID string `json:"fileId" jsonschema:"file-id from search tool output. required."` + Lines string `json:"lines,omitempty" jsonschema:"line range A,B (optional, default: entire file)"` +} + +type DepsInput struct { + Project string `json:"project,omitempty" jsonschema:"project path (optional, default: . (cwd))"` + Scope string `json:"scope,omitempty" jsonschema:"dependency scope (optional, default: compile)"` + Config []string `json:"config,omitempty" jsonschema:"Gradle config filters (optional, default: scope defaults)"` + Targets []string `json:"targets,omitempty" jsonschema:"target filters (optional, default: all targets)"` + Subprojects []string `json:"subprojects,omitempty" jsonschema:"subproject filters (optional, default: all subprojects)"` + Buildsrc *bool `json:"buildsrc,omitempty" jsonschema:"include buildSrc (optional, default: true)"` + Buildscript *bool `json:"buildscript,omitempty" jsonschema:"include buildscript (optional, default: true)"` + IncludeBuilds *bool `json:"includeBuilds,omitempty" jsonschema:"include builds (optional, default: true)"` + Group string `json:"group,omitempty" jsonschema:"group filter (optional, default: all dependencies)"` + Artifact string `json:"artifact,omitempty" jsonschema:"artifact filter (optional, default: all artifacts)"` + Version string `json:"version,omitempty" jsonschema:"version filter (optional, default: all versions)"` +} + +type FetchInput struct { + Group string `json:"group" jsonschema:"group. required."` + Artifact string `json:"artifact" jsonschema:"artifact. required."` + Version string `json:"version" jsonschema:"version. required."` + Project string `json:"project,omitempty" jsonschema:"project path (optional, default: .)"` + Buildsrc *bool `json:"buildsrc,omitempty" jsonschema:"include buildSrc (optional, default: true)"` + Buildscript *bool `json:"buildscript,omitempty" jsonschema:"include buildscript (optional, default: true)"` + IncludeBuilds *bool `json:"includeBuilds,omitempty" jsonschema:"include builds (optional, default: true)"` +} + +type ResolveInput struct { + Project string `json:"project,omitempty" jsonschema:"project path (optional, default: .)"` + Group string `json:"group,omitempty" jsonschema:"group filter (optional, default: all dependencies)"` + Artifact string `json:"artifact,omitempty" jsonschema:"artifact filter (optional, default: all artifacts)"` + Version string `json:"version,omitempty" jsonschema:"version filter (optional, default: all versions)"` + Scope string `json:"scope,omitempty" jsonschema:"dependency scope (optional, default: compile)"` + Config []string `json:"config,omitempty" jsonschema:"Gradle config filters (optional, default: scope defaults)"` + Targets []string `json:"targets,omitempty" jsonschema:"target filters (optional, default: all targets)"` + Subprojects []string `json:"subprojects,omitempty" jsonschema:"subproject filters (optional, default: all subprojects)"` + Buildsrc *bool `json:"buildsrc,omitempty" jsonschema:"include buildSrc (optional, default: true)"` + Buildscript *bool `json:"buildscript,omitempty" jsonschema:"include buildscript (optional, default: true)"` + IncludeBuilds *bool `json:"includeBuilds,omitempty" jsonschema:"include builds (optional, default: true)"` +} + +type WhereInput struct { + PathOrCoord string `json:"pathOrCoord" jsonschema:"file-id or path/coord. required."` + Project string `json:"project,omitempty" jsonschema:"project path (optional, default: . (cwd))"` + Group string `json:"group,omitempty" jsonschema:"group filter (optional, default: all dependencies; required for path lookup)"` + Artifact string `json:"artifact,omitempty" jsonschema:"artifact filter (optional, default: all artifacts; required for path lookup)"` + Version string `json:"version,omitempty" jsonschema:"version filter (optional, default: all versions)"` + Scope string `json:"scope,omitempty" jsonschema:"dependency scope (optional, default: compile)"` + Config []string `json:"config,omitempty" jsonschema:"Gradle config filters (optional, default: scope defaults)"` + Targets []string `json:"targets,omitempty" jsonschema:"target filters (optional, default: all targets)"` + Subprojects []string `json:"subprojects,omitempty" jsonschema:"subproject filters (optional, default: all subprojects)"` + Buildsrc *bool `json:"buildsrc,omitempty" jsonschema:"include buildSrc (optional, default: true)"` + Buildscript *bool `json:"buildscript,omitempty" jsonschema:"include buildscript (optional, default: true)"` + IncludeBuilds *bool `json:"includeBuilds,omitempty" jsonschema:"include builds (optional, default: true)"` +} + +func (s *toolState) handleSearch(ctx context.Context, call *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := decodeInput[SearchInput](call) + if err != nil { + return nil, err + } + query := strings.TrimSpace(input.Query) + if query == "" { + return toolError(fmt.Errorf("query is required")), nil + } + resReq := resolution.Request{ + Project: withDefaultString(input.Project, "."), + Group: strings.TrimSpace(input.Group), + Artifact: strings.TrimSpace(input.Artifact), + Version: strings.TrimSpace(input.Version), + Scope: withDefaultString(input.Scope, "compile"), + Config: joinCSV(input.Config), + Targets: joinCSV(input.Targets), + Subprojects: cleanList(input.Subprojects), + IncludeBuildSrc: true, + IncludeBuildscript: true, + IncludeIncludedBuilds: true, + ApplyFilters: true, + AllowCacheFallback: true, + } + if resReq.Group == "" && resReq.Artifact == "" && resReq.Version == "" { + resReq.All = true + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, resReq) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + if len(result.Sources) == 0 { + return toolError(noSourcesError(resReq.Group, resReq.Artifact, resReq.Version)), nil + } + if _, err := s.runner.LookPath("rg"); err != nil { + return toolError(fmt.Errorf("rg not found on PATH, ask the user to install ripgrep first. The user can run `ksrc doctor` to get guidance.")), nil + } + + rgArgs := cleanList(input.RgArgs) + if input.Context > 0 { + rgArgs = append(rgArgs, "-C", fmt.Sprintf("%d", input.Context)) + } + + var report func(search.ExecPlan) + if s.verbose { + report = func(plan search.ExecPlan) { + rgLine := fmt.Sprintf("rg: %s %s", plan.Cmd, formatRgArgs(plan)) + fmt.Fprintf(os.Stderr, "VERBOSE: %s\n", rgLine) + fmt.Fprintf(os.Stderr, "VERBOSE: rg jars: %d (mode=%s)\n", plan.JarCount, plan.Mode) + } + } + + matches, err := search.Run(ctx, s.runner, search.Options{ + Pattern: query, + Jars: result.Sources, + RGArgs: rgArgs, + WorkDir: resReq.Project, + Report: report, + }) + if err != nil { + return toolError(err), nil + } + if len(matches) == 0 { + return textResult("no results"), nil + } + + var sb strings.Builder + for _, m := range matches { + fmt.Fprintf(&sb, "%s %d:%d:%s\n", m.FileID, m.Line, m.Column, m.Text) + } + return textResult(sb.String()), nil +} + +func (s *toolState) handleCat(ctx context.Context, call *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := decodeInput[CatInput](call) + if err != nil { + return nil, err + } + fileID := strings.TrimSpace(input.FileID) + if fileID == "" { + return toolError(fmt.Errorf("fileId is required. Obtain it from `search` tool output, the file id is the string before the line:column")), nil + } + coord, inner, err := resolve.ParseFileID(fileID) + if err != nil { + return toolError(err), nil + } + lr, err := cat.ParseLineRange(input.Lines) + if err != nil { + return toolError(err), nil + } + + req := resolution.Request{ + Project: ".", + Group: coord.Group, + Artifact: coord.Artifact, + Version: coord.Version, + Scope: "compile", + IncludeBuildSrc: true, + IncludeBuildscript: true, + IncludeIncludedBuilds: true, + ApplyFilters: true, + AllowCacheFallback: true, + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, req) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + if len(result.Sources) == 0 { + return toolError(noSourcesError(coord.Group, coord.Artifact, coord.Version)), nil + } + jarPath, err := findJarByCoord(result.Sources, coord) + if err != nil { + return toolError(err), nil + } + data, err := cat.ReadFileFromZip(jarPath, inner, lr) + if err != nil { + return toolError(err), nil + } + return textResult(string(data)), nil +} + +func (s *toolState) handleDeps(ctx context.Context, call *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := decodeInput[DepsInput](call) + if err != nil { + return nil, err + } + resReq := resolution.Request{ + Project: withDefaultString(input.Project, "."), + Scope: withDefaultString(input.Scope, "compile"), + Config: joinCSV(input.Config), + Targets: joinCSV(input.Targets), + Subprojects: cleanList(input.Subprojects), + IncludeBuildSrc: boolOrDefault(input.Buildsrc, true), + IncludeBuildscript: boolOrDefault(input.Buildscript, true), + IncludeIncludedBuilds: boolOrDefault(input.IncludeBuilds, true), + ApplyFilters: false, + AllowCacheFallback: false, + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, resReq) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + + filteredSources := resolve.FilterSources(result.Sources, "", input.Group, input.Artifact, input.Version) + filteredDeps := filterCoords(result.Deps, input.Group, input.Artifact, input.Version) + + var sb strings.Builder + writeDepsOutput(&sb, filteredSources, filteredDeps) + return textResult(sb.String()), nil +} + +func (s *toolState) handleFetch(ctx context.Context, call *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := decodeInput[FetchInput](call) + if err != nil { + return nil, err + } + group := strings.TrimSpace(input.Group) + artifact := strings.TrimSpace(input.Artifact) + version := strings.TrimSpace(input.Version) + if group == "" || artifact == "" || version == "" { + return toolError(fmt.Errorf("group, artifact, and version are required. Obtain them from the file id returned by `search` or `deps`")), nil + } + coord := resolve.Coord{Group: group, Artifact: artifact, Version: version} + resReq := resolution.Request{ + Project: withDefaultString(input.Project, "."), + Group: group, + Artifact: artifact, + Version: version, + Scope: "compile", + IncludeBuildSrc: boolOrDefault(input.Buildsrc, true), + IncludeBuildscript: boolOrDefault(input.Buildscript, true), + IncludeIncludedBuilds: boolOrDefault(input.IncludeBuilds, true), + Dep: coord.String(), + ApplyFilters: false, + AllowCacheFallback: false, + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, resReq) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + if len(result.Sources) == 0 { + return toolError(noSourcesError(group, artifact, version)), nil + } + var sb strings.Builder + for _, src := range result.Sources { + if src.Coord.Group == group && src.Coord.Artifact == artifact && src.Coord.Version == version { + fmt.Fprintf(&sb, "%s|%s\n", src.Coord.String(), src.Path) + } + } + return textResult(sb.String()), nil +} + +func (s *toolState) handleResolve(ctx context.Context, call *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := decodeInput[ResolveInput](call) + if err != nil { + return nil, err + } + resReq := resolution.Request{ + Project: withDefaultString(input.Project, "."), + Group: strings.TrimSpace(input.Group), + Artifact: strings.TrimSpace(input.Artifact), + Version: strings.TrimSpace(input.Version), + Scope: withDefaultString(input.Scope, "compile"), + Config: joinCSV(input.Config), + Targets: joinCSV(input.Targets), + Subprojects: cleanList(input.Subprojects), + IncludeBuildSrc: boolOrDefault(input.Buildsrc, true), + IncludeBuildscript: boolOrDefault(input.Buildscript, true), + IncludeIncludedBuilds: boolOrDefault(input.IncludeBuilds, true), + ApplyFilters: true, + AllowCacheFallback: true, + } + if resReq.Group == "" && resReq.Artifact == "" && resReq.Version == "" { + resReq.All = true + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, resReq) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + if len(result.Sources) == 0 { + return toolError(noSourcesError(resReq.Group, resReq.Artifact, resReq.Version)), nil + } + var sb strings.Builder + for _, src := range result.Sources { + fmt.Fprintf(&sb, "%s|%s\n", src.Coord.String(), src.Path) + } + return textResult(sb.String()), nil +} + +func (s *toolState) handleWhere(ctx context.Context, call *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := decodeInput[WhereInput](call) + if err != nil { + return nil, err + } + arg := strings.TrimSpace(input.PathOrCoord) + if arg == "" { + return toolError(fmt.Errorf("pathOrCoord is required")), nil + } + if strings.Contains(arg, "!/") { + coord, inner, err := resolve.ParseFileID(arg) + if err != nil { + return toolError(err), nil + } + resReq := resolution.Request{ + Project: withDefaultString(input.Project, "."), + Group: coord.Group, + Artifact: coord.Artifact, + Version: coord.Version, + Scope: withDefaultString(input.Scope, "compile"), + Config: joinCSV(input.Config), + Targets: joinCSV(input.Targets), + Subprojects: cleanList(input.Subprojects), + IncludeBuildSrc: boolOrDefault(input.Buildsrc, true), + IncludeBuildscript: boolOrDefault(input.Buildscript, true), + IncludeIncludedBuilds: boolOrDefault(input.IncludeBuilds, true), + Dep: coord.String(), + ApplyFilters: true, + AllowCacheFallback: true, + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, resReq) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + if len(result.Sources) == 0 { + return toolError(noSourcesError(coord.Group, coord.Artifact, coord.Version)), nil + } + jarPath, err := findJarByCoord(result.Sources, coord) + if err != nil { + return toolError(err), nil + } + return textResult(fmt.Sprintf("%s|%s\n", coord.String()+"!/"+inner, jarPath)), nil + } + if coord, err := resolve.ParseCoord(arg); err == nil { + dep := "" + if coord.Version != "" { + dep = coord.String() + } + resReq := resolution.Request{ + Project: withDefaultString(input.Project, "."), + Group: coord.Group, + Artifact: coord.Artifact, + Version: coord.Version, + Scope: withDefaultString(input.Scope, "compile"), + Config: joinCSV(input.Config), + Targets: joinCSV(input.Targets), + Subprojects: cleanList(input.Subprojects), + IncludeBuildSrc: boolOrDefault(input.Buildsrc, true), + IncludeBuildscript: boolOrDefault(input.Buildscript, true), + IncludeIncludedBuilds: boolOrDefault(input.IncludeBuilds, true), + Dep: dep, + ApplyFilters: true, + AllowCacheFallback: true, + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, resReq) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + if len(result.Sources) == 0 { + return toolError(noSourcesError(coord.Group, coord.Artifact, coord.Version)), nil + } + jarPath, err := findJarByCoord(result.Sources, coord) + if err != nil { + return toolError(err), nil + } + return textResult(fmt.Sprintf("%s|%s\n", coord.String(), jarPath)), nil + } + + group := strings.TrimSpace(input.Group) + artifact := strings.TrimSpace(input.Artifact) + version := strings.TrimSpace(input.Version) + if group == "" || artifact == "" { + return toolError(fmt.Errorf("path requires group and artifact filters or a file-id")), nil + } + path := strings.TrimPrefix(arg, "/") + resReq := resolution.Request{ + Project: withDefaultString(input.Project, "."), + Group: group, + Artifact: artifact, + Version: version, + Scope: withDefaultString(input.Scope, "compile"), + Config: joinCSV(input.Config), + Targets: joinCSV(input.Targets), + Subprojects: cleanList(input.Subprojects), + IncludeBuildSrc: boolOrDefault(input.Buildsrc, true), + IncludeBuildscript: boolOrDefault(input.Buildscript, true), + IncludeIncludedBuilds: boolOrDefault(input.IncludeBuilds, true), + ApplyFilters: true, + AllowCacheFallback: true, + } + service := resolution.Service{Runner: s.runner, Verbose: s.verbose} + result, err := service.ResolveSources(ctx, resReq) + if err != nil { + return toolError(err), nil + } + emitDiagnostics(result.Meta, s.verbose) + if len(result.Sources) == 0 { + return toolError(noSourcesError(group, artifact, version)), nil + } + jarPath, coord, inner, err := findFileInJars(result.Sources, path) + if err != nil { + return toolError(err), nil + } + return textResult(fmt.Sprintf("%s|%s\n", coord.String()+"!/"+inner, jarPath)), nil +} + +func textResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +func toolError(err error) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + } +} + +func decodeInput[T any](req *mcp.CallToolRequest) (T, error) { + var input T + if req == nil || req.Params == nil || req.Params.Arguments == nil { + return input, nil + } + if err := json.Unmarshal(req.Params.Arguments, &input); err != nil { + return input, err + } + return input, nil +} + +func mustInputSchema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](nil) + if err != nil { + panic(err) + } + return schema +} + +func findJarByCoord(sources []resolve.SourceJar, coord resolve.Coord) (string, error) { + for _, src := range sources { + if src.Coord.Group == coord.Group && src.Coord.Artifact == coord.Artifact && src.Coord.Version == coord.Version { + return src.Path, nil + } + } + return "", fmt.Errorf("source jar not found for %s. Try: calling `fetch` tool, or if you don't see it, ask the user to enable with `ksrc mcp --tools=all`", coord.String()) +} + +func findFileInJars(sources []resolve.SourceJar, inner string) (string, resolve.Coord, string, error) { + inner = strings.TrimPrefix(inner, "/") + for _, src := range sources { + if _, err := cat.ReadFileFromZip(src.Path, inner, nil); err == nil { + return src.Path, src.Coord, inner, nil + } + } + return "", resolve.Coord{}, "", fmt.Errorf("file not found in resolved sources: %s. Try specifying: `project` (for monorepos), `scope` for build time deps etc., or `configs` for non-standard compilations.", inner) +} + +func formatRgArgs(plan search.ExecPlan) string { + args := plan.Args + if plan.JarCount > 0 && len(args) >= plan.JarCount { + trimmed := append([]string{}, args[:len(args)-plan.JarCount]...) + trimmed = append(trimmed, fmt.Sprintf("<%d jars>", plan.JarCount)) + return strings.Join(trimmed, " ") + } + return strings.Join(args, " ") +} + +func writeDepsOutput(sb *strings.Builder, sources []resolve.SourceJar, deps []resolve.Coord) { + sourceByCoord := make(map[string]string) + for _, src := range sources { + sourceByCoord[src.Coord.String()] = src.Path + } + + seen := make(map[string]struct{}) + for _, dep := range deps { + key := dep.String() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + path := sourceByCoord[key] + sourcesYes := "no" + if path != "" { + sourcesYes = "yes" + } + fmt.Fprintf(sb, "%s [sources: %s] [path: %s]\n", key, sourcesYes, path) + } + + if len(deps) == 0 { + for _, src := range sources { + key := src.Coord.String() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + fmt.Fprintf(sb, "%s [sources: yes] [path: %s]\n", key, src.Path) + } + } +} diff --git a/internal/mcpserver/handlers_empty_test.go b/internal/mcpserver/handlers_empty_test.go new file mode 100644 index 0000000..874dc5a --- /dev/null +++ b/internal/mcpserver/handlers_empty_test.go @@ -0,0 +1,61 @@ +package mcpserver + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/respawn-app/ksrc/internal/resolve" +) + +func TestFindFileInJarsAllowsEmptyFile(t *testing.T) { + jarPath := filepath.Join(t.TempDir(), "empty.jar") + inner := "com/example/Empty.kt" + if err := writeZipFileEmpty(jarPath, inner, ""); err != nil { + t.Fatalf("write zip: %v", err) + } + + sources := []resolve.SourceJar{{ + Coord: resolve.Coord{Group: "com.example", Artifact: "demo", Version: "1.0.0"}, + Path: jarPath, + }} + + path, coord, foundInner, err := findFileInJars(sources, inner) + if err != nil { + t.Fatalf("findFileInJars error: %v", err) + } + if path != jarPath { + t.Fatalf("unexpected jar path: %s", path) + } + if coord.Group != "com.example" || coord.Artifact != "demo" || coord.Version != "1.0.0" { + t.Fatalf("unexpected coord: %+v", coord) + } + if foundInner != inner { + t.Fatalf("unexpected inner path: %s", foundInner) + } +} + +func writeZipFileEmpty(path, inner, content string) error { + f, err := os.Create(path) + if err != nil { + return err + } + zw := zip.NewWriter(f) + w, err := zw.Create(inner) + if err != nil { + _ = zw.Close() + _ = f.Close() + return err + } + if _, err := w.Write([]byte(content)); err != nil { + _ = zw.Close() + _ = f.Close() + return err + } + if err := zw.Close(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} diff --git a/internal/mcpserver/helpers.go b/internal/mcpserver/helpers.go new file mode 100644 index 0000000..e67aef9 --- /dev/null +++ b/internal/mcpserver/helpers.go @@ -0,0 +1,106 @@ +package mcpserver + +import ( + "fmt" + "os" + "strings" + + "github.com/respawn-app/ksrc/internal/resolution" + "github.com/respawn-app/ksrc/internal/resolve" +) + +func joinCSV(values []string) string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + out = append(out, value) + } + if len(out) == 0 { + return "" + } + return strings.Join(out, ",") +} + +func cleanList(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + out = append(out, value) + } + if len(out) == 0 { + return nil + } + return out +} + +func boolOrDefault(value *bool, def bool) bool { + if value == nil { + return def + } + return *value +} + +func withDefaultString(value string, def string) string { + value = strings.TrimSpace(value) + if value == "" { + return def + } + return value +} + +func noSourcesError(group, artifact, version string) error { + msg := "E_NO_SOURCES: no sources resolved." + return fmt.Errorf("%s %s", msg, noSourcesHint(group, artifact, version)) +} + +func noSourcesHint(group, artifact, version string) string { + if group != "" && artifact != "" && version != "" { + return fmt.Sprintf("Try: ksrc fetch %s:%s:%s to download sources.", group, artifact, version) + } + if group != "" && artifact != "" { + return "Try: add a version (group:artifact:version) or run ksrc deps to see resolved coords." + } + return "Try: ksrc deps (list resolved coords), then ksrc fetch to download sources." +} + +func filterCoords(coords []resolve.Coord, group, artifact, version string) []resolve.Coord { + if group == "" && artifact == "" && version == "" { + return coords + } + out := make([]resolve.Coord, 0, len(coords)) + for _, coord := range coords { + if !resolve.MatchAny(group, coord.Group) { + continue + } + if !resolve.MatchAny(artifact, coord.Artifact) { + continue + } + if !resolve.MatchAny(version, coord.Version) { + continue + } + out = append(out, coord) + } + return out +} + +func emitDiagnostics(meta resolution.ResolveMeta, verbose bool) { + for _, warning := range meta.Warnings { + fmt.Fprintf(os.Stderr, "WARN: %s\n", warning) + } + if !verbose { + return + } + for _, line := range meta.Verbose { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fmt.Fprintf(os.Stderr, "VERBOSE: %s\n", line) + } +} diff --git a/internal/mcpserver/integration_test.go b/internal/mcpserver/integration_test.go new file mode 100644 index 0000000..bc02ef4 --- /dev/null +++ b/internal/mcpserver/integration_test.go @@ -0,0 +1,156 @@ +package mcpserver + +import ( + "archive/zip" + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/respawn-app/ksrc/internal/cat" +) + +func TestMCPServerSearchAndCatIntegration(t *testing.T) { + if _, err := exec.LookPath("rg"); err != nil { + t.Skip("rg not available") + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + root := filepath.Clean(filepath.Join(wd, "..", "..")) + projectDir := filepath.Join(root, "testdata", "fixture") + jarPath := filepath.Join(t.TempDir(), "kotlinx-datetime-sources.jar") + inner := "kotlinx/datetime/LocalDate.kt" + + if err := writeZipFile(jarPath, inner, "before\npublic class LocalDate\nafter\n"); err != nil { + t.Fatalf("write jar: %v", err) + } + if _, err := cat.ReadFileFromZip(jarPath, inner, nil); err != nil { + t.Fatalf("jar missing test file: %v", err) + } + + binPath := filepath.Join(t.TempDir(), "ksrc") + buildCmd := exec.Command("go", "build", "-o", binPath, "./cmd/ksrc") + buildCmd.Dir = root + buildOut, err := buildCmd.CombinedOutput() + if err != nil { + t.Fatalf("build ksrc: %v\n%s", err, string(buildOut)) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binPath, "mcp") + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), "KSRC_TEST_JAR="+jarPath) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatalf("stdin pipe: %v", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + t.Fatalf("start mcp: %v", err) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, &mcp.IOTransport{Reader: stdout, Writer: stdin}, nil) + if err != nil { + _ = cmd.Process.Kill() + t.Fatalf("connect mcp: %v", err) + } + + searchRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search", + Arguments: map[string]any{ + "query": "public class LocalDate", + "group": "org.jetbrains.kotlinx", + "artifact": "kotlinx-datetime", + "project": projectDir, + "subprojects": []string{}, + }, + }) + if err != nil { + _ = session.Close() + _ = cmd.Process.Kill() + t.Fatalf("search tool: %v", err) + } + searchText := textFromResult(searchRes) + expectedFileID := "org.jetbrains.kotlinx:kotlinx-datetime:0.6.1!/" + inner + if !strings.Contains(searchText, expectedFileID) { + t.Fatalf("unexpected search output: %s", searchText) + } + fileID := expectedFileID + + catRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "cat", + Arguments: map[string]any{ + "fileId": fileID, + "lines": "2,2", + }, + }) + if err != nil { + _ = session.Close() + _ = cmd.Process.Kill() + t.Fatalf("cat tool: %v", err) + } + catText := strings.TrimSpace(textFromResult(catRes)) + if catText != "public class LocalDate" { + t.Fatalf("unexpected cat output: %q", catText) + } + + _ = session.Close() + _ = stdin.Close() + _ = stdout.Close() + _ = cmd.Wait() + _ = stderr +} + +func textFromResult(res *mcp.CallToolResult) string { + if res == nil { + return "" + } + var sb strings.Builder + for _, c := range res.Content { + if text, ok := c.(*mcp.TextContent); ok { + sb.WriteString(text.Text) + } + } + return sb.String() +} + +func writeZipFile(path, inner, content string) error { + f, err := os.Create(path) + if err != nil { + return err + } + zw := zip.NewWriter(f) + w, err := zw.Create(inner) + if err != nil { + _ = zw.Close() + _ = f.Close() + return err + } + if _, err := w.Write([]byte(content)); err != nil { + _ = zw.Close() + _ = f.Close() + return err + } + if err := zw.Close(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..58e7dad --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,38 @@ +package mcpserver + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/respawn-app/ksrc/internal/executil" +) + +type Options struct { + Runner executil.Runner + Verbose bool + Tools ToolSet + Version string +} + +func Run(ctx context.Context, opts Options) error { + if opts.Runner == nil { + return fmt.Errorf("runner is required") + } + if opts.Version == "" { + opts.Version = "dev" + } + if opts.Tools == nil { + opts.Tools = DefaultTools() + } + server := mcp.NewServer(&mcp.Implementation{ + Name: "ksrc", + Version: opts.Version, + }, nil) + + state := &toolState{runner: opts.Runner, verbose: opts.Verbose} + registerTools(server, state, opts.Tools) + + transport := &mcp.StdioTransport{} + return server.Run(ctx, transport) +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go new file mode 100644 index 0000000..c4938f4 --- /dev/null +++ b/internal/mcpserver/tools.go @@ -0,0 +1,99 @@ +package mcpserver + +import ( + "fmt" + "strings" +) + +const ( + ToolSearch = "search" + ToolCat = "cat" + ToolDeps = "deps" + ToolFetch = "fetch" + ToolResolve = "resolve" + ToolWhere = "where" +) + +var allToolNames = []string{ + ToolSearch, + ToolCat, + ToolDeps, + ToolFetch, + ToolResolve, + ToolWhere, +} + +var defaultToolNames = []string{ + ToolSearch, + ToolCat, + ToolDeps, +} + +type ToolSet map[string]bool + +func DefaultTools() ToolSet { + return toolSetFromList(defaultToolNames) +} + +func AllTools() ToolSet { + return toolSetFromList(allToolNames) +} + +func ParseTools(value string) (ToolSet, error) { + value = strings.TrimSpace(value) + if value == "" { + return DefaultTools(), nil + } + if value == "all" { + return AllTools(), nil + } + parts := strings.Split(value, ",") + items := make([]string, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + items = append(items, name) + } + if len(items) == 0 { + return DefaultTools(), nil + } + set := make(ToolSet) + for _, name := range items { + if !isKnownTool(name) { + return nil, fmt.Errorf("unknown tool: %s", name) + } + set[name] = true + } + return set, nil +} + +func (t ToolSet) Enabled(name string) bool { + return t[name] +} + +func toolSetFromList(names []string) ToolSet { + set := make(ToolSet) + for _, name := range names { + set[name] = true + } + return set +} + +func isKnownTool(name string) bool { + for _, tool := range allToolNames { + if tool == name { + return true + } + } + return false +} + +func KnownTools() []string { + return append([]string{}, allToolNames...) +} + +func DefaultToolNames() []string { + return append([]string{}, defaultToolNames...) +} diff --git a/internal/mcpserver/tools_test.go b/internal/mcpserver/tools_test.go new file mode 100644 index 0000000..9017058 --- /dev/null +++ b/internal/mcpserver/tools_test.go @@ -0,0 +1,47 @@ +package mcpserver + +import "testing" + +func TestParseToolsDefaults(t *testing.T) { + set, err := ParseTools("") + if err != nil { + t.Fatalf("ParseTools error: %v", err) + } + for _, name := range DefaultToolNames() { + if !set.Enabled(name) { + t.Fatalf("expected default tool %s", name) + } + } +} + +func TestParseToolsAll(t *testing.T) { + set, err := ParseTools("all") + if err != nil { + t.Fatalf("ParseTools error: %v", err) + } + for _, name := range KnownTools() { + if !set.Enabled(name) { + t.Fatalf("expected tool %s", name) + } + } +} + +func TestParseToolsList(t *testing.T) { + set, err := ParseTools("search,cat") + if err != nil { + t.Fatalf("ParseTools error: %v", err) + } + if !set.Enabled(ToolSearch) || !set.Enabled(ToolCat) { + t.Fatalf("expected search and cat enabled") + } + if set.Enabled(ToolDeps) { + t.Fatalf("expected deps disabled") + } +} + +func TestParseToolsUnknown(t *testing.T) { + _, err := ParseTools("search,wat") + if err == nil { + t.Fatalf("expected error for unknown tool") + } +}