diff --git a/.claude/skills/ccg-docs/SKILL.md b/.claude/skills/ccg-docs/SKILL.md index b41459a..1ee8d33 100644 --- a/.claude/skills/ccg-docs/SKILL.md +++ b/.claude/skills/ccg-docs/SKILL.md @@ -56,15 +56,6 @@ User: "RAG 인덱스 만들어줘" → Creates searchable document tree from docs + communities ``` -### Build RAG index from namespace docs -``` -User: "my-service namespace 문서로 RAG 인덱스 만들어줘" -→ upload_file(namespace: "my-service", file_path: "docs/handler.go.md", content: "") -→ build_rag_index(namespace: "my-service") -→ search_docs(query: "handler") -→ get_doc_content(namespace: "my-service", file_path: "docs/handler.go.md") -``` - ### Check documentation quality ``` User: "문서 상태 체크해줘" diff --git a/.claude/skills/ccg-workspace/SKILL.md b/.claude/skills/ccg-workspace/SKILL.md deleted file mode 100644 index 7d91867..0000000 --- a/.claude/skills/ccg-workspace/SKILL.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -name: ccg-workspace -description: code-context-graph — namespace file management. Upload, list, and delete files in isolated namespace directories for MSA source management. ---- - -# code-context-graph — Namespace File Management - -Manage namespace file directories for uploading, organizing, and deleting source files. Designed for MSA environments where each namespace represents a service. The skill name remains `ccg-workspace` as a legacy alias. - -## MCP Tools (6) - -| Tool | Description | -|------|-------------| -| `upload_file` | Upload a single file to namespace (base64 encoded content) | -| `upload_files` | Upload multiple files to namespaces in a single call (JSON array) | -| `list_namespaces` | List all namespaces | -| `list_workspaces` | Deprecated alias for `list_namespaces` | -| `list_files` | List files in a namespace | -| `delete_file` | Delete a single file from namespace | -| `delete_namespace` | Delete an entire namespace and all its files | -| `delete_workspace` | Deprecated alias for `delete_namespace` | - -## Namespace Directory Structure - -``` -{namespace-root}/ -├── payment-svc/ -│ ├── handler.go -│ └── service.go -├── user-svc/ -│ ├── auth.go -│ └── profile.go -└── gateway/ - └── router.go -``` - -- Canonical root flag is `--namespace-root ` (default: `workspaces`); `--workspace-root` remains a deprecated alias -- Each namespace maps to a service/module directory: `{namespace}/{file}` -- File content is uploaded as base64-encoded strings - -## Usage Examples - -### Upload a single file -``` -→ upload_file(namespace: "payment-svc", file_path: "handler.go", content: "") -``` - -### Bulk upload multiple files -``` -→ upload_files(files: '[{"namespace":"payment-svc","file_path":"handler.go","content":""},{"namespace":"payment-svc","file_path":"service.go","content":""}]') -``` - -Note: `files` parameter is a JSON string containing an array of file entries. - -### List all namespaces -``` -→ list_namespaces() -→ Returns: ["payment-svc", "user-svc", "gateway"] -``` - -### List files in a namespace -``` -→ list_files(namespace: "payment-svc") -→ Returns: ["handler.go", "service.go"] -``` - -### Delete a file -``` -→ delete_file(namespace: "payment-svc", file_path: "handler.go") -``` - -### Delete entire namespace -``` -→ delete_namespace(namespace: "payment-svc") -→ Removes payment-svc/ directory and all files within -``` - -## E2E Pipeline: Upload → Build → Search - -After uploading files, build the graph and search: - -``` -1. upload_file(namespace: "payment-svc", file_path: "handler.go", content: "") -2. build_or_update_graph(path: "{namespace-root}/payment-svc") — see /ccg skill -3. search(query: "payment") — see /ccg skill -``` - -## E2E Pipeline: Upload Docs → RAG Index → Search → Read - -Upload documentation files to a namespace directory, then build and query the RAG index: - -``` -1. upload_file(namespace: "my-service", file_path: "docs/internal/handler.go.md", content: "") -2. build_rag_index(namespace: "my-service") — see /ccg-docs skill -3. search_docs(query: "handler", namespace: "my-service") -4. get_rag_tree(namespace: "my-service") -5. get_doc_content(namespace: "my-service", file_path: "docs/internal/handler.go.md") -``` - -## Security - -- Path traversal attacks are blocked (`../` in namespace or file_path) -- File size is validated before writing -- Namespace names are sanitized diff --git a/.claude/skills/ccg/SKILL.md b/.claude/skills/ccg/SKILL.md index 7c7b8ab..52abaf6 100644 --- a/.claude/skills/ccg/SKILL.md +++ b/.claude/skills/ccg/SKILL.md @@ -60,7 +60,6 @@ Related skills: /ccg-analyze — Code analysis & architecture /ccg-annotate — Annotation system & AI workflow /ccg-docs — Documentation & RAG indexing - /ccg-workspace — Namespace file management ``` ## MCP Tools (7) diff --git a/AGENTS.md b/AGENTS.md index d6b3ecb..43e9701 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,13 @@ The ccg MCP server registered in `.mcp.json` provides 33 tools: - `parse_project`, `build_or_update_graph`, `run_postprocess` - `get_postprocess_policy`, `reset_postprocess_policy` -- `get_node`, `search`, `query_graph`, `list_graph_stats`, `get_minimal_context` +- `get_node`, `search`, `query_graph`, `list_graph_stats`, `list_namespaces`, `get_minimal_context` - `get_impact_radius`, `trace_flow` - `find_large_functions`, `find_dead_code` - `detect_changes`, `get_affected_flows`, `list_flows` - `list_communities`, `get_community`, `get_architecture_overview` - `get_annotation` - `build_rag_index`, `get_rag_tree`, `get_doc_content`, `search_docs`, `retrieve_docs` -- `upload_file`, `upload_files`, `list_namespaces`, `list_files`, `delete_file`, `delete_namespace` `ccg serve` is the local stdio MCP entry point. Self-hosted HTTP mode is provided by the separate `ccg-server` binary, which serves `/mcp`, `/health`, `/ready`, `/status`, diff --git a/CLAUDE.md b/CLAUDE.md index e22713c..f780d11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,16 +6,15 @@ Follow the global prompt rules first. This file adds project-specific skill rout ## MCP 서버 -`.mcp.json`에 등록된 ccg MCP 서버가 26개 도구를 제공합니다: +`.mcp.json`에 등록된 ccg MCP 서버가 21개 도구를 제공합니다: - `parse_project`, `build_or_update_graph`, `run_postprocess` - `get_postprocess_policy`, `reset_postprocess_policy` -- `get_node`, `search`, `query_graph`, `list_graph_stats`, `get_minimal_context` +- `get_node`, `search`, `query_graph`, `list_graph_stats`, `list_namespaces`, `get_minimal_context` - `get_impact_radius`, `trace_flow`, `find_suspect_fallback_edges` - `detect_changes`, `get_affected_flows`, `list_flows` - `get_annotation` - `get_doc_content`, `search_docs`, `retrieve_docs` -- `upload_file`, `upload_files`, `list_files`, `delete_file`, `list_namespaces`, `delete_namespace` HTTP 모드 (`--transport streamable-http`)에서는 `/health` 및 `/webhook` 엔드포인트도 제공합니다. Webhook은 `--allow-repo` 플래그로 허용 리포지토리를 설정하면 활성화됩니다. @@ -24,15 +23,14 @@ GitHub (`X-Hub-Signature-256`) 및 Gitea (`X-Gitea-Signature`, `X-Gitea-Event`) Push 이벤트 수신 → 자동 clone/pull → 그래프 빌드 → DB 저장 파이프라인. Graceful shutdown: SIGINT/SIGTERM 시 진행 중인 clone/build에 context cancel 전파. -## CLI Skills (5개) +## CLI Skills (4개) -| Skill | 설명 | -| ---------------- | ----------------------------------------------------------- | -| `/ccg` | 코어 빌드 & 검색 — 파싱, 그래프 빌드, 쿼리, 검색 | -| `/ccg-analyze` | 코드 분석 — 영향 반경, 플로우 추적, 데드코드, 아키텍처 | -| `/ccg-annotate` | 어노테이션 시스템 — AI 어노테이션 워크플로우, 태그 레퍼런스 | -| `/ccg-docs` | 문서 — 문서 생성, RAG 인덱싱, lint | -| `/ccg-workspace` | 파일 워크스페이스 — 파일/워크스페이스 업로드, 목록, 삭제 | +| Skill | 설명 | +| --------------- | ----------------------------------------------------------- | +| `/ccg` | 코어 빌드 & 검색 — 파싱, 그래프 빌드, 쿼리, 검색 | +| `/ccg-analyze` | 코드 분석 — 영향 반경, 플로우 추적, 데드코드, 아키텍처 | +| `/ccg-annotate` | 어노테이션 시스템 — AI 어노테이션 워크플로우, 태그 레퍼런스 | +| `/ccg-docs` | 문서 — 문서 생성, RAG 인덱싱, lint | 주요 커맨드: diff --git a/guide/ko/mcp-tools.md b/guide/ko/mcp-tools.md index 6f5590b..9328f5d 100644 --- a/guide/ko/mcp-tools.md +++ b/guide/ko/mcp-tools.md @@ -139,29 +139,21 @@ RAG 인덱스 품질은 생성 문서와 비어 있지 않은 community postproc `run_postprocess`를 `communities=true`, `flows=false`, `fts=false`로 호출하십시오. -### 네임스페이스 파일 관리 (Namespace File Management) +### 네임스페이스 탐색 (Namespace Discovery) -업로드 파일, 서비스별 그래프 데이터, 네임스페이스별 RAG 인덱스의 격리 단위는 -`namespace`입니다. +서비스별 그래프 데이터와 네임스페이스별 RAG 인덱스의 격리 단위는 `namespace`입니다. | 도구 | 설명 | |------|-------------| -| `upload_file` | 네임스페이스에 파일 업로드 (base64) | -| `upload_files` | 단일 호출로 여러 네임스페이스에 다수 파일 업로드 | -| `list_namespaces` | 모든 네임스페이스 목록 출력 | -| `list_files` | 네임스페이스 내 파일 목록 출력 | -| `delete_file` | 네임스페이스에서 파일 삭제 | -| `delete_namespace` | 네임스페이스 전체 및 관련 파일 모두 삭제 | +| `list_namespaces` | 그래프 데이터를 가진 네임스페이스를 네임스페이스별 노드 수와 함께 출력 | 정식 예시: ``` -upload_file(namespace: "payment-svc", file_path: "handler.go", content: "") -list_files(namespace: "payment-svc") -delete_namespace(namespace: "payment-svc") +list_namespaces() // -> [{namespace: "payment-svc", node_count: 128}, ...] ``` -## Agent Skills (5개) +## Agent Skills (4개) | 스킬 | 설명 | |-------|-------------| @@ -169,7 +161,6 @@ delete_namespace(namespace: "payment-svc") | `/ccg-analyze` | 코드 분석 — 영향 범위, 흐름 추적, 데드 코드, 아키텍처 | | `/ccg-annotate` | 어노테이션 시스템 — AI 기반 어노테이션 워크플로우, 태그 레퍼런스 | | `/ccg-docs` | 문서화 — 문서 생성, RAG 인덱싱, 린트 | -| `/ccg-namespace` | 네임스페이스 파일 관리 — 네임스페이스 파일 업로드, 목록 출력, 삭제 | 이 스킬 파일들은 `skills/`에 있으며 slash-command 스타일의 에이전트 워크플로우를 위해 작성되었습니다. 일반적인 코딩 에이전트 작업을 적절한 diff --git a/guide/mcp-tools.md b/guide/mcp-tools.md index 896658c..b3a6167 100644 --- a/guide/mcp-tools.md +++ b/guide/mcp-tools.md @@ -144,29 +144,22 @@ when it is configured. In MCP-only workflows, run `run_postprocess` with `communities=true`, `flows=false`, and `fts=false` before `build_rag_index` when communities may be missing. -### Namespace File Management +### Namespace Discovery -Use `namespace` as the isolation term for uploaded files, per-service graph data, -and namespace-specific RAG indexes. +`namespace` is the isolation term for per-service graph data and namespace-specific +RAG indexes. | Tool | Description | |------|-------------| -| `upload_file` | Upload file to namespace (base64) | -| `upload_files` | Upload multiple files to namespaces in a single call | -| `list_namespaces` | List all namespaces | -| `list_files` | List files in a namespace | -| `delete_file` | Delete file from namespace | -| `delete_namespace` | Delete an entire namespace and all its files | +| `list_namespaces` | List namespaces that hold graph data, with per-namespace node counts | -Canonical examples: +Canonical example: ``` -upload_file(namespace: "payment-svc", file_path: "handler.go", content: "") -list_files(namespace: "payment-svc") -delete_namespace(namespace: "payment-svc") +list_namespaces() // -> [{namespace: "payment-svc", node_count: 128}, ...] ``` -## Agent Skills (5) +## Agent Skills (4) | Skill | Description | |-------|-------------| @@ -174,7 +167,6 @@ delete_namespace(namespace: "payment-svc") | `/ccg-analyze` | Code analysis — impact radius, flow tracing, dead code, architecture | | `/ccg-annotate` | Annotation system — AI-driven annotation workflow, tag reference | | `/ccg-docs` | Documentation — generate docs, RAG indexing, lint | -| `/ccg-namespace` | Namespace file management — upload, list, and delete namespace files | These skill files live in `skills/` and are written for slash-command style agent workflows. They route common coding-agent tasks to the right CLI and MCP diff --git a/internal/mcp/e2e_test.go b/internal/mcp/e2e_test.go index 2fe8493..1703ac2 100644 --- a/internal/mcp/e2e_test.go +++ b/internal/mcp/e2e_test.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -524,101 +523,3 @@ func Gamma() {} t.Errorf("expected at least 3 nodes, got %v", resp["total_nodes"]) } } - -func TestE2E_UploadBuildSearch(t *testing.T) { - deps := setupE2EDeps(t) - - wsRoot := t.TempDir() - deps.NamespaceRoot = wsRoot - - // Step 1: Upload Go source into the namespace with upload_file. - goSrc := `package payment - -func ProcessPayment(amount int) error { - return nil -} - -func RefundPayment(txID string) error { - return nil -} -` - encoded := base64.StdEncoding.EncodeToString([]byte(goSrc)) - - uploadResult := callTool(t, deps, "upload_file", map[string]any{ - "namespace": "payment-svc", - "file_path": "payment.go", - "content": encoded, - }) - if uploadResult.IsError { - t.Fatalf("upload_file error: %s", getTextContent(uploadResult)) - } - - // Step 2: Build the namespace path with build_or_update_graph. - wsPath := filepath.Join(wsRoot, "payment-svc") - buildResult := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": wsPath, - "full_rebuild": true, - "postprocess": "none", - }) - if buildResult.IsError { - t.Fatalf("build_or_update_graph error: %s", getTextContent(buildResult)) - } - - // Step 3: Build the search index. - ctx := context.Background() - nodesInFile, err := deps.Store.GetNodesByFile(ctx, "payment.go") - if err != nil { - t.Fatal(err) - } - if len(nodesInFile) == 0 { - t.Fatal("expected at least 1 node after build") - } - for _, n := range nodesInFile { - if n.Kind == model.NodeKindFunction { - deps.DB.Create(&model.SearchDocument{ - NodeID: n.ID, - Content: n.Name + " " + n.QualifiedName, - Language: n.Language, - }) - } - } - deps.SearchBackend.Rebuild(ctx, deps.DB) - - // Step 4: Verify with search. - searchResult := callTool(t, deps, "search", map[string]any{"query": "ProcessPayment", "limit": 10}) - if searchResult.IsError { - t.Fatalf("search error: %s", getTextContent(searchResult)) - } - searchText := getTextContent(searchResult) - var results []map[string]any - if err := json.Unmarshal([]byte(searchText), &results); err != nil { - t.Fatalf("search result not JSON array: %s", searchText) - } - if len(results) == 0 { - t.Fatal("expected at least 1 search result for 'ProcessPayment'") - } - - found := false - for _, r := range results { - if name, ok := r["name"].(string); ok && name == "ProcessPayment" { - found = true - } - } - if !found { - t.Error("expected ProcessPayment in search results") - } - - // Step 5: Confirm Refund is searchable too. - refundResult := callTool(t, deps, "search", map[string]any{"query": "RefundPayment", "limit": 10}) - if refundResult.IsError { - t.Fatalf("search error: %s", getTextContent(refundResult)) - } - refundText := getTextContent(refundResult) - var refundResults []map[string]any - if err := json.Unmarshal([]byte(refundText), &refundResults); err != nil { - t.Fatalf("search result not JSON array: %s", refundText) - } - if len(refundResults) == 0 { - t.Fatal("expected at least 1 search result for 'RefundPayment'") - } -} diff --git a/internal/mcp/handler_namespace.go b/internal/mcp/handler_namespace.go index 4753ef6..90eeca6 100644 --- a/internal/mcp/handler_namespace.go +++ b/internal/mcp/handler_namespace.go @@ -1,149 +1,35 @@ -// @index MCP handlers for namespace uploads, listing, and deletion. +// @index MCP handler that enumerates graph namespaces for cross-namespace discovery. package mcp import ( "context" - "errors" - "fmt" - "io/fs" - "os" "github.com/mark3labs/mcp-go/mcp" + "github.com/tae2089/trace" - "github.com/tae2089/code-context-graph/internal/ctxns" - nsfs "github.com/tae2089/code-context-graph/internal/namespacefs" + "github.com/tae2089/code-context-graph/internal/model" "github.com/tae2089/code-context-graph/internal/paging" - "github.com/tae2089/code-context-graph/internal/service" ) -// namespaceFileResult summarizes one uploaded namespace file. -// @intent preserve a stable per-file DTO for namespace upload responses. -type namespaceFileResult struct { +// namespaceCount pairs a namespace with how many graph nodes it holds. +// @intent give list_namespaces a typed row for the distinct-namespace aggregate. +type namespaceCount struct { Namespace string `json:"namespace"` - FilePath string `json:"file_path"` - Size int `json:"size,omitempty"` + NodeCount int64 `json:"node_count"` } -// namespaceUploadResponse is the typed wire payload for uploadFile and uploadFiles. -// @intent preserve a stable confirmation envelope for single and bulk namespace uploads. -type namespaceUploadResponse struct { - Status string `json:"status"` - Namespace string `json:"namespace,omitempty"` - FilePath string `json:"file_path,omitempty"` - Size int `json:"size,omitempty"` - Uploaded int `json:"uploaded,omitempty"` - Files []namespaceFileResult `json:"files,omitempty"` +// listNamespacesResponse is the wire payload for list_namespaces. +// @intent report which namespaces contain graph data so callers can scope later queries. +type listNamespacesResponse struct { + Namespaces []namespaceCount `json:"namespaces"` + Count int `json:"count"` + Pagination paging.Page `json:"pagination"` } -// namespaceDeleteResponse is the typed wire payload for deleteFile and deleteNamespace. -// @intent preserve a stable confirmation envelope for namespace deletion operations. -type namespaceDeleteResponse struct { - Status string `json:"status"` - Namespace string `json:"namespace"` - FilePath string `json:"file_path,omitempty"` -} - -// namespaceListResponse is the typed wire payload for listNamespaces and listFiles. -// @intent preserve a stable paged list envelope while omitting irrelevant legacy fields. -type namespaceListResponse struct { - Namespaces []string `json:"namespaces,omitempty"` - Files []string `json:"files,omitempty"` - Items []string `json:"items"` - Count int `json:"count"` - Pagination paging.Page `json:"pagination"` -} - -// namespaceRoot returns the filesystem root used for namespace storage. -// @intent ensure all file upload tools use the same namespace root. -// @return Returns the default "namespaces" directory if no configuration value is set. -func (h *handlers) namespaceRoot() string { - root := h.deps.NamespaceRoot - if root == "" { - root = "namespaces" - } - return root -} - -// @intent build a namespacefs.Service bound to the current handler's resolved root. -func (h *handlers) namespaceService() *nsfs.Service { - return nsfs.NewService(h.namespaceRoot()) -} - -// @intent require the canonical namespace parameter for namespace file operations. -func requireNamespace(request mcp.CallToolRequest) (string, error) { - return request.RequireString("namespace") -} - -// validateNamespacePath validates namespace and file paths against traversal. -// @intent thin MCP-side delegator shared across handler files. -func validateNamespacePath(namespace, filePath string) error { - return nsfs.ValidatePath(namespace, filePath) -} - -// @intent reject symlink traversal anywhere along a namespace path before file operations touch the filesystem. -func ensureNoSymlinkInPath(root, relPath string, allowMissingLeaf bool) (string, error) { - return nsfs.EnsureNoSymlinkInPath(root, relPath, allowMissingLeaf) -} - -// @intent resolve a namespace-relative path under the trusted root after validation and symlink checks. -func (h *handlers) resolveNamespacePath(namespace, filePath string, allowMissingLeaf bool) (string, error) { - return h.namespaceService().ResolvePath(namespace, filePath, allowMissingLeaf) -} - -// @intent map namespacefs.Service errors to MCP user-error responses. -func namespaceErrorResult(err error, fallbackPrefix string) *mcp.CallToolResult { - if nsfs.IsValidationError(err) { - return mcp.NewToolResultError(err.Error()) - } - if fallbackPrefix == "" { - return mcp.NewToolResultError(err.Error()) - } - return mcp.NewToolResultError(fmt.Sprintf("%s: %v", fallbackPrefix, err)) -} - -// uploadFile writes one base64-encoded file into a namespace. -// @intent Uploads a single file to the server namespace for subsequent analysis or documentation tasks. -// @param request content is the base64-encoded file bytes. -// @requires namespace and file_path must be safe relative paths. -// @ensures Returns the stored file path and size on success. -// @domainRule Uploaded files cannot exceed 10MB. -// @sideEffect Performs directory creation and file writes. -func (h *handlers) uploadFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace, err := requireNamespace(request) - if err != nil { - return missingParamResult(err) - } - filePath, err := request.RequireString("file_path") - if err != nil { - return missingParamResult(err) - } - contentB64, err := request.RequireString("content") - if err != nil { - return missingParamResult(err) - } - - res, err := h.namespaceService().UploadFile(nsfs.UploadRequest{ - Namespace: namespace, - FilePath: filePath, - ContentBase64: contentB64, - }) - if err != nil { - return namespaceErrorResult(err, ""), nil - } - - jsonStr, _ := marshalJSON(namespaceUploadResponse{ - Status: "ok", - Namespace: res.Namespace, - FilePath: res.FilePath, - Size: res.Size, - }) - return mcp.NewToolResultText(jsonStr), nil -} - -// listNamespaces lists available namespace directories. -// @intent Lists namespace names on the server to aid in selecting an upload target. -// @ensures Returns an array of namespace names on success. -// @sideEffect Performs a filesystem directory read. +// listNamespaces lists namespaces that hold graph nodes, with per-namespace node counts. +// @intent let agents discover available namespaces before scoping search or graph queries. +// @param request limit and offset control pagination; the query spans all namespaces. +// @ensures Returns namespaces sorted by name with node counts and pagination metadata. func (h *handlers) listNamespaces(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { limit := request.GetInt("limit", 50) offset := request.GetInt("offset", 0) @@ -158,201 +44,26 @@ func (h *handlers) listNamespaces(ctx context.Context, request mcp.CallToolReque return finalizeToolResult("", newToolResultErr(err.Error())) } - namespaces, err := h.namespaceService().ListNamespaces() - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - hasMore := false - if pageReq.Offset >= len(namespaces) { - namespaces = []string{} - } else { - namespaces = namespaces[pageReq.Offset:] - } - if len(namespaces) > pageReq.Limit { - namespaces = namespaces[:pageReq.Limit] - hasMore = true - } - - jsonStr, _ := marshalJSON(namespaceListResponse{ - Namespaces: namespaces, - Items: namespaces, - Count: len(namespaces), - Pagination: paging.BuildPage(pageReq, len(namespaces), hasMore), - }) - return mcp.NewToolResultText(jsonStr), nil -} - -// listFiles lists all files stored inside a namespace. -// @intent Enables checking the current file configuration of a specific namespace. -// @param request namespace is the name of the namespace to check. -// @requires namespace must be a safe relative path. -// @ensures Returns an array of relative file paths inside the namespace on success. -// @sideEffect Performs a filesystem traversal. -func (h *handlers) listFiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace, err := requireNamespace(request) - if err != nil { - return missingParamResult(err) - } - limit := request.GetInt("limit", 50) - offset := request.GetInt("offset", 0) - if err := validatePositiveLimit(limit); err != nil { - return finalizeToolResult("", err) - } - if err := validateOffset(offset); err != nil { - return finalizeToolResult("", err) - } - pageReq, err := paging.Normalize(paging.Request{Limit: limit, Offset: offset}) - if err != nil { - return finalizeToolResult("", newToolResultErr(err.Error())) - } - - files, err := h.namespaceService().ListFiles(namespace) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - jsonStr, _ := marshalJSON(namespaceListResponse{ - Files: []string{}, - Items: []string{}, - Count: 0, - Pagination: paging.BuildPage(pageReq, 0, false), - }) - return mcp.NewToolResultText(jsonStr), nil - } - return namespaceErrorResult(err, ""), nil - } - - hasMore := false - if pageReq.Offset >= len(files) { - files = []string{} - } else { - files = files[pageReq.Offset:] - } - if len(files) > pageReq.Limit { - files = files[:pageReq.Limit] - hasMore = true - } - - jsonStr, _ := marshalJSON(namespaceListResponse{ - Files: files, - Items: files, - Count: len(files), - Pagination: paging.BuildPage(pageReq, len(files), hasMore), - }) - return mcp.NewToolResultText(jsonStr), nil -} - -// deleteFile removes one file from a namespace. -// @intent Allows individual cleanup of namespace files that are no longer needed. -// @param request Selects the deletion target via namespace and file_path. -// @requires The target file must exist in the specified namespace. -// @ensures Returns information about the deleted file on success. -// @sideEffect Deletes the actual file from the filesystem. -func (h *handlers) deleteFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace, err := requireNamespace(request) - if err != nil { - return missingParamResult(err) - } - filePath, err := request.RequireString("file_path") - if err != nil { - return missingParamResult(err) - } - - if err := h.namespaceService().DeleteFile(namespace, filePath); err != nil { - return namespaceErrorResult(err, ""), nil - } - - jsonStr, _ := marshalJSON(namespaceDeleteResponse{ - Status: "deleted", - Namespace: namespace, - FilePath: filePath, - }) - return mcp.NewToolResultText(jsonStr), nil -} - -// uploadFiles writes multiple base64-encoded files in one request. -// @intent Reduces round-trip costs by uploading multiple namespace files in a single MCP call. -// @param request files is a JSON string containing an array of upload entries. -// @requires The files array must not be empty, and each entry must be valid. -// @ensures Returns the number of uploaded files and information for each file on success. -// @domainRule Each file is limited to 10MB, the total decoded payload to 20MB, the raw request to 50MB, and all paths must be safe. -// @sideEffect Performs directory creation and multiple file writes. -func (h *handlers) uploadFiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - filesRaw, err := request.RequireString("files") - if err != nil { - return missingParamResult(err) - } - - results, err := h.namespaceService().UploadFiles(filesRaw) - if err != nil { - var bulkErr *nsfs.BulkEntryError - if errors.As(err, &bulkErr) { - return mcp.NewToolResultError(bulkErr.Error()), nil - } - if nsfs.IsValidationError(err) { - return mcp.NewToolResultError(err.Error()), nil - } - return mcp.NewToolResultError(err.Error()), nil - } - - files := make([]namespaceFileResult, 0, len(results)) - for _, r := range results { - files = append(files, namespaceFileResult{ - Namespace: r.Namespace, - FilePath: r.FilePath, - Size: r.Size, - }) - } - - jsonStr, _ := marshalJSON(namespaceUploadResponse{ - Status: "ok", - Uploaded: len(files), - Files: files, - }) - return mcp.NewToolResultText(jsonStr), nil -} - -// deleteNamespace removes an entire namespace directory tree. -// @intent Enables bulk cleanup of uploaded file sets by namespace. -// @param request namespace is the name of the namespace to delete. -// @requires namespace must be a safe relative path and must actually exist. -// @ensures Returns the name of the deleted namespace on success. -// @sideEffect Recursively deletes the namespace directory, RAG index, and namespaced graph state. -func (h *handlers) deleteNamespace(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace, err := requireNamespace(request) - if err != nil { - return missingParamResult(err) - } - - svc := h.namespaceService() - nsDir, err := svc.ResolveExistingNamespace(namespace) - if err != nil { - return namespaceErrorResult(err, ""), nil - } - - if h.deps != nil && h.deps.Store != nil { - purger := service.NewNamespacePurger(h.deps.Store, h.deps.DB, h.deps.SearchBackend) - if err := purger.Purge(ctxns.WithNamespace(ctx, namespace)); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("purge namespace: %v", err)), nil - } - } - - if indexPath, err := h.resolvedRagIndexPath(namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("resolve rag index path: %v", err)), nil - } else if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) { - return mcp.NewToolResultError(fmt.Sprintf("delete namespace rag index: %v", err)), nil - } - - if err := svc.RemoveTree(nsDir); err != nil { - return mcp.NewToolResultError(err.Error()), nil + rows := []namespaceCount{} + if err := h.deps.DB.WithContext(ctx). + Model(&model.Node{}). + Select("namespace, COUNT(*) AS node_count"). + Group("namespace"). + Order("namespace ASC"). + Limit(pageReq.Limit + 1). + Offset(pageReq.Offset). + Scan(&rows).Error; err != nil { + return finalizeToolResult("", trace.Wrap(err, "list namespaces")) } - if h.cache != nil { - h.cache.Flush() + hasMore := len(rows) > pageReq.Limit + if hasMore { + rows = rows[:pageReq.Limit] } - jsonStr, _ := marshalJSON(namespaceDeleteResponse{ - Status: "deleted", - Namespace: namespace, - }) - return mcp.NewToolResultText(jsonStr), nil + return finalizeToolResult(marshalJSON(listNamespacesResponse{ + Namespaces: rows, + Count: len(rows), + Pagination: paging.BuildPage(pageReq, len(rows), hasMore), + })) } diff --git a/internal/mcp/handler_namespace_test.go b/internal/mcp/handler_namespace_test.go index 5c804ab..6bf9ab4 100644 --- a/internal/mcp/handler_namespace_test.go +++ b/internal/mcp/handler_namespace_test.go @@ -2,1035 +2,50 @@ package mcp import ( "context" - "encoding/base64" "encoding/json" - "errors" - "os" - "path/filepath" - "strings" "testing" - "time" - - "github.com/mark3labs/mcp-go/mcp" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" "github.com/tae2089/code-context-graph/internal/ctxns" "github.com/tae2089/code-context-graph/internal/model" - nsfs "github.com/tae2089/code-context-graph/internal/namespacefs" - "github.com/tae2089/code-context-graph/internal/store" - "github.com/tae2089/code-context-graph/internal/store/gormstore" - storesearch "github.com/tae2089/code-context-graph/internal/store/search" ) -type failDeleteGraphStore struct { - store.GraphStore - err error -} - -func (f *failDeleteGraphStore) DeleteGraph(ctx context.Context) error { return f.err } - -type spySearchBackend struct { - storesearch.Backend - purgeCalls []string - purgeErr error - lastDB *gorm.DB -} - -func (s *spySearchBackend) PurgeNamespace(ctx context.Context, db *gorm.DB) error { - s.purgeCalls = append(s.purgeCalls, ctxns.FromContext(ctx)) - s.lastDB = db - return s.purgeErr -} - -func (s *spySearchBackend) RebuildNodes(ctx context.Context, db *gorm.DB, nodeIDs []uint) error { - return nil -} - -func namespaceHandlers(t *testing.T) (*handlers, string) { - t.Helper() - root := t.TempDir() - h := &handlers{ - deps: &Deps{ - NamespaceRoot: root, - }, - } - return h, root -} - -func TestUploadFile_Basic(t *testing.T) { - h, root := namespaceHandlers(t) - - content := "# Hello World\nThis is a test." - encoded := base64.StdEncoding.EncodeToString([]byte(content)) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "my-service", - "file_path": "docs/readme.md", - "content": encoded, - }) - - result, err := h.uploadFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - written, err := os.ReadFile(filepath.Join(root, "my-service", "docs", "readme.md")) - if err != nil { - t.Fatalf("file not written: %v", err) - } - if string(written) != content { - t.Errorf("content mismatch: got %q, want %q", string(written), content) - } -} - -func TestUploadFile_AcceptsNamespace(t *testing.T) { - h, root := namespaceHandlers(t) - encoded := base64.StdEncoding.EncodeToString([]byte("hello")) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "my-service", - "file_path": "docs/readme.md", - "content": encoded, - }) - - result, err := h.uploadFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - if _, err := os.Stat(filepath.Join(root, "my-service", "docs", "readme.md")); err != nil { - t.Fatalf("file not written via namespace: %v", err) - } -} - -func TestUploadFile_PathTraversal(t *testing.T) { - h, _ := namespaceHandlers(t) - - encoded := base64.StdEncoding.EncodeToString([]byte("malicious")) - - tests := []struct { - name string - namespace string - filePath string - }{ - {"dotdot in namespace", "../evil", "file.md"}, - {"dotdot in file_path", "ok", "../../etc/passwd"}, - {"absolute namespace", "/etc", "passwd"}, - {"absolute file_path", "ok", "/etc/passwd"}, - {"dot namespace", ".", "file.md"}, - {"double-dot namespace", "..", "file.md"}, - {"path-like namespace slash", "service/api", "file.md"}, - {"path-like namespace separator", "service" + string(filepath.Separator) + "api", "file.md"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := makeCallToolRequest(t, map[string]any{ - "namespace": tt.namespace, - "file_path": tt.filePath, - "content": encoded, - }) - result, err := h.uploadFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - }) - } -} - -func TestUploadFile_RejectsEmptyNamespace(t *testing.T) { - h, _ := namespaceHandlers(t) - encoded := base64.StdEncoding.EncodeToString([]byte("content")) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "", - "file_path": "file.md", - "content": encoded, - }) - result, err := h.uploadFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestUploadFile_InvalidBase64(t *testing.T) { - h, _ := namespaceHandlers(t) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "my-service", - "file_path": "file.md", - "content": "not-valid-base64!!!", - }) - - result, err := h.uploadFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestListNamespaces_Empty(t *testing.T) { - h, _ := namespaceHandlers(t) - - req := makeCallToolRequest(t, map[string]any{}) - result, err := h.listNamespaces(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - text := extractText(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - if _, ok := resp["files"]; ok { - t.Fatalf("expected files field to be omitted, got %v", resp["files"]) - } - if items := resp["items"].([]any); len(items) != 0 { - t.Errorf("expected empty items list, got %v", items) - } - if count := resp["count"].(float64); count != 0 { - t.Errorf("expected count 0, got %v", count) - } - if _, ok := resp["pagination"].(map[string]any); !ok { - t.Fatal("expected pagination object") - } -} - -func TestListNamespaces_WithData(t *testing.T) { - h, root := namespaceHandlers(t) - - os.MkdirAll(filepath.Join(root, "service-a"), 0o755) - os.MkdirAll(filepath.Join(root, "service-b"), 0o755) - - req := makeCallToolRequest(t, map[string]any{}) - result, err := h.listNamespaces(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - text := extractText(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - namespaces := resp["namespaces"].([]any) - if len(namespaces) != 2 { - t.Errorf("expected 2 namespaces, got %d: %v", len(namespaces), namespaces) - } -} - -func TestListNamespaces_Pagination(t *testing.T) { - h, root := namespaceHandlers(t) - for _, name := range []string{"service-a", "service-b", "service-c"} { - if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { - t.Fatal(err) - } - } - - req := makeCallToolRequest(t, map[string]any{"limit": 2, "offset": 1}) - result, err := h.listNamespaces(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - var resp map[string]any - if err := json.Unmarshal([]byte(extractText(result)), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - items := resp["items"].([]any) - if len(items) != 2 { - t.Fatalf("expected 2 items, got %d", len(items)) - } - pagination := resp["pagination"].(map[string]any) - if pagination["offset"].(float64) != 1 || pagination["returned"].(float64) != 2 { - t.Fatalf("unexpected pagination: %v", pagination) - } -} - -func TestListFiles_Basic(t *testing.T) { - h, root := namespaceHandlers(t) - - nsDir := filepath.Join(root, "my-service") - os.MkdirAll(filepath.Join(nsDir, "docs"), 0o755) - os.WriteFile(filepath.Join(nsDir, "readme.md"), []byte("hello"), 0o644) - os.WriteFile(filepath.Join(nsDir, "docs", "api.md"), []byte("api"), 0o644) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "my-service", - }) - result, err := h.listFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - text := extractText(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - if _, ok := resp["namespaces"]; ok { - t.Fatalf("expected namespaces field to be omitted, got %v", resp["namespaces"]) - } - files := resp["files"].([]any) - if len(files) != 2 { - t.Errorf("expected 2 files, got %d: %v", len(files), files) - } -} - -func TestListFiles_Pagination(t *testing.T) { - h, root := namespaceHandlers(t) - nsDir := filepath.Join(root, "my-service") - if err := os.MkdirAll(filepath.Join(nsDir, "docs"), 0o755); err != nil { - t.Fatal(err) - } - for _, rel := range []string{"a.md", "b.md", "docs/c.md"} { - if err := os.WriteFile(filepath.Join(nsDir, rel), []byte(rel), 0o644); err != nil { - t.Fatal(err) - } - } - - req := makeCallToolRequest(t, map[string]any{"namespace": "my-service", "limit": 2, "offset": 1}) - result, err := h.listFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - var resp map[string]any - if err := json.Unmarshal([]byte(extractText(result)), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - files := resp["files"].([]any) - if len(files) != 2 { - t.Fatalf("expected 2 files, got %d", len(files)) - } - pagination := resp["pagination"].(map[string]any) - if pagination["offset"].(float64) != 1 || pagination["returned"].(float64) != 2 { - t.Fatalf("unexpected pagination: %v", pagination) - } -} - -func TestListNamespaceAndFiles_InvalidPagination(t *testing.T) { - h, _ := namespaceHandlers(t) - for name, req := range map[string]mcp.CallToolRequest{ - "namespaces limit": makeCallToolRequest(t, map[string]any{"limit": 0}), - "namespaces offset": makeCallToolRequest(t, map[string]any{"offset": -1}), - "files limit": makeCallToolRequest(t, map[string]any{"namespace": "svc", "limit": 0}), - "files offset": makeCallToolRequest(t, map[string]any{"namespace": "svc", "offset": -1}), - } { - t.Run(name, func(t *testing.T) { - var result *mcp.CallToolResult - var err error - if strings.HasPrefix(name, "namespaces") { - result, err = h.listNamespaces(t.Context(), req) - } else { - result, err = h.listFiles(t.Context(), req) - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - }) - } -} - -func TestListFiles_PathTraversal(t *testing.T) { - h, _ := namespaceHandlers(t) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "../evil", - }) - result, err := h.listFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestDeleteFile_Basic(t *testing.T) { - h, root := namespaceHandlers(t) - - nsDir := filepath.Join(root, "my-service") - os.MkdirAll(nsDir, 0o755) - filePath := filepath.Join(nsDir, "to-delete.md") - os.WriteFile(filePath, []byte("bye"), 0o644) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "my-service", - "file_path": "to-delete.md", - }) - result, err := h.deleteFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - if _, err := os.Stat(filePath); !os.IsNotExist(err) { - t.Errorf("file should have been deleted") - } -} - -func TestDeleteFile_NotFound(t *testing.T) { - h, _ := namespaceHandlers(t) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "my-service", - "file_path": "nonexistent.md", - }) - result, err := h.deleteFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestDeleteFile_PathTraversal(t *testing.T) { - h, _ := namespaceHandlers(t) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "ok", - "file_path": "../../etc/passwd", - }) - result, err := h.deleteFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestUploadFiles_Basic(t *testing.T) { - h, root := namespaceHandlers(t) - - file1 := base64.StdEncoding.EncodeToString([]byte("package main")) - file2 := base64.StdEncoding.EncodeToString([]byte("package util")) - - filesJSON, _ := json.Marshal([]map[string]string{ - {"namespace": "svc-a", "file_path": "main.go", "content": file1}, - {"namespace": "svc-a", "file_path": "util.go", "content": file2}, - }) - - req := makeCallToolRequest(t, map[string]any{ - "files": string(filesJSON), - }) - - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - text := extractText(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - uploaded := resp["uploaded"].(float64) - if uploaded != 2 { - t.Errorf("expected 2 uploaded, got %v", uploaded) - } - - if _, err := os.Stat(filepath.Join(root, "svc-a", "main.go")); os.IsNotExist(err) { - t.Error("main.go not written") - } - if _, err := os.Stat(filepath.Join(root, "svc-a", "util.go")); os.IsNotExist(err) { - t.Error("util.go not written") - } -} - -func TestUploadFiles_EmptyArray(t *testing.T) { - h, _ := namespaceHandlers(t) - - req := makeCallToolRequest(t, map[string]any{ - "files": "[]", - }) - - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestUploadFiles_InvalidJSON(t *testing.T) { - h, _ := namespaceHandlers(t) - - req := makeCallToolRequest(t, map[string]any{ - "files": "not-json", - }) - - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestUploadFiles_PathTraversal(t *testing.T) { - h, _ := namespaceHandlers(t) - - file1 := base64.StdEncoding.EncodeToString([]byte("data")) - filesJSON, _ := json.Marshal([]map[string]string{ - {"namespace": "../evil", "file_path": "file.go", "content": file1}, - }) - - req := makeCallToolRequest(t, map[string]any{ - "files": string(filesJSON), - }) - - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestUploadFiles_RejectsOversizedRawRequest(t *testing.T) { - h, _ := namespaceHandlers(t) - req := makeCallToolRequest(t, map[string]any{ - "files": strings.Repeat("x", nsfs.DefaultMaxRequestBytes+1), - }) - - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - if got := getTextContent(result); !strings.Contains(got, "total upload request exceeds") { - t.Fatalf("expected total request size error, got %q", got) - } -} - -func TestUploadFiles_RejectsOversizedTotalDecodedContent(t *testing.T) { - h, root := namespaceHandlers(t) - first := base64.StdEncoding.EncodeToString(make([]byte, nsfs.DefaultMaxFileBytes)) - second := base64.StdEncoding.EncodeToString(make([]byte, nsfs.DefaultMaxFileBytes)) - third := base64.StdEncoding.EncodeToString([]byte("a")) - filesJSON, _ := json.Marshal([]map[string]string{ - {"namespace": "svc-a", "file_path": "a.txt", "content": first}, - {"namespace": "svc-b", "file_path": "b.txt", "content": second}, - {"namespace": "svc-c", "file_path": "c.txt", "content": third}, - }) - req := makeCallToolRequest(t, map[string]any{"files": string(filesJSON)}) - - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - if got := getTextContent(result); !strings.Contains(got, "total decoded upload exceeds") { - t.Fatalf("expected total decoded size error, got %q", got) - } - for _, path := range []string{ - filepath.Join(root, "svc-a", "a.txt"), - filepath.Join(root, "svc-b", "b.txt"), - filepath.Join(root, "svc-c", "c.txt"), - } { - if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { - t.Fatalf("expected oversized bulk upload to leave no files, path=%s stat err=%v", path, statErr) - } - } -} - -func TestUploadFiles_MultipleNamespaces(t *testing.T) { - h, root := namespaceHandlers(t) - - file1 := base64.StdEncoding.EncodeToString([]byte("package a")) - file2 := base64.StdEncoding.EncodeToString([]byte("package b")) - - filesJSON, _ := json.Marshal([]map[string]string{ - {"namespace": "svc-a", "file_path": "a.go", "content": file1}, - {"namespace": "svc-b", "file_path": "b.go", "content": file2}, - }) - - req := makeCallToolRequest(t, map[string]any{ - "files": string(filesJSON), - }) - - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - if _, err := os.Stat(filepath.Join(root, "svc-a", "a.go")); os.IsNotExist(err) { - t.Error("svc-a/a.go not written") - } - if _, err := os.Stat(filepath.Join(root, "svc-b", "b.go")); os.IsNotExist(err) { - t.Error("svc-b/b.go not written") - } -} - -func TestDeleteNamespace_Basic(t *testing.T) { - h, root := namespaceHandlers(t) - - nsDir := filepath.Join(root, "to-delete") - os.MkdirAll(filepath.Join(nsDir, "subdir"), 0o755) - os.WriteFile(filepath.Join(nsDir, "file.md"), []byte("content"), 0o644) - os.WriteFile(filepath.Join(nsDir, "subdir", "nested.md"), []byte("nested"), 0o644) - - req := makeCallToolRequest(t, map[string]any{ - "namespace": "to-delete", - }) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - if _, err := os.Stat(nsDir); !os.IsNotExist(err) { - t.Error("namespace directory should have been deleted") - } -} - -func TestDeleteNamespace_PurgesNamespaceGraphRAGAndCache(t *testing.T) { - namespaceRoot := t.TempDir() - ragRoot := t.TempDir() - cache := NewCache(time.Minute) - t.Cleanup(cache.Close) - cache.Set("search:svc", "stale") - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) - if err != nil { - t.Fatalf("open db: %v", err) - } - st := gormstore.New(db) - if err := st.AutoMigrate(); err != nil { - t.Fatalf("migrate store: %v", err) - } - if err := db.AutoMigrate(&model.SearchDocument{}, &model.Flow{}, &model.FlowMembership{}); err != nil { - t.Fatalf("migrate extras: %v", err) - } - if err := db.AutoMigrate(&model.Community{}, &model.CommunityMembership{}); err != nil { - t.Fatalf("migrate communities: %v", err) - } - - h := &handlers{ - deps: &Deps{ - NamespaceRoot: namespaceRoot, - RagIndexDir: ragRoot, - Store: st, - DB: db, - SearchBackend: &spySearchBackend{}, - }, - cache: cache, - } - - nsDir := filepath.Join(namespaceRoot, "svc") - if err := os.MkdirAll(nsDir, 0o755); err != nil { - t.Fatalf("mkdir namespace: %v", err) - } - if err := os.WriteFile(filepath.Join(nsDir, "file.go"), []byte("package svc"), 0o644); err != nil { - t.Fatalf("write namespace file: %v", err) - } - - ragIndexPath := filepath.Join(ragRoot, "svc", "doc-index.json") - if err := os.MkdirAll(filepath.Dir(ragIndexPath), 0o755); err != nil { - t.Fatalf("mkdir rag dir: %v", err) - } - if err := os.WriteFile(ragIndexPath, []byte(`{"community_count":1}`), 0o644); err != nil { - t.Fatalf("write rag index: %v", err) - } - - ctx := ctxns.WithNamespace(context.Background(), "svc") - if err := st.UpsertNodes(ctx, []model.Node{{QualifiedName: "svc.Handler", Kind: model.NodeKindFunction, Name: "Handler", FilePath: "file.go", StartLine: 1, EndLine: 2, Language: "go"}}); err != nil { - t.Fatalf("seed namespaced node: %v", err) - } - svcNode, err := st.GetNode(ctx, "svc.Handler") - if err != nil || svcNode == nil { - t.Fatalf("load namespaced node: %v", err) - } - if err := st.UpsertNodes(context.Background(), []model.Node{{QualifiedName: "other.Handler", Kind: model.NodeKindFunction, Name: "Handler", FilePath: "other.go", StartLine: 1, EndLine: 2, Language: "go"}}); err != nil { - t.Fatalf("seed legacy node: %v", err) - } - - svcCommunity := model.Community{Namespace: "svc", Key: "svc/core", Label: "svc/core", Strategy: "directory"} - if err := db.Create(&svcCommunity).Error; err != nil { - t.Fatalf("seed svc community: %v", err) - } - if err := db.Create(&model.CommunityMembership{CommunityID: svcCommunity.ID, NodeID: svcNode.ID}).Error; err != nil { - t.Fatalf("seed svc community membership: %v", err) - } - svcFlow := model.Flow{Namespace: "svc", Name: "svc-flow"} - if err := db.Create(&svcFlow).Error; err != nil { - t.Fatalf("seed svc flow: %v", err) - } - if err := db.Create(&model.FlowMembership{Namespace: "svc", FlowID: svcFlow.ID, NodeID: svcNode.ID, Ordinal: 0}).Error; err != nil { - t.Fatalf("seed svc flow membership: %v", err) - } - - otherCommunity := model.Community{Namespace: "other", Key: "other/core", Label: "other/core", Strategy: "directory"} - if err := db.Create(&otherCommunity).Error; err != nil { - t.Fatalf("seed control community: %v", err) - } - otherFlow := model.Flow{Namespace: "other", Name: "other-flow"} - if err := db.Create(&otherFlow).Error; err != nil { - t.Fatalf("seed control flow: %v", err) - } - - req := makeCallToolRequest(t, map[string]any{"namespace": "svc"}) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - if _, err := os.Stat(nsDir); !os.IsNotExist(err) { - t.Fatal("namespace directory should have been deleted") - } - if _, err := os.Stat(ragIndexPath); !os.IsNotExist(err) { - t.Fatal("namespace rag index should have been deleted") - } - if _, ok := cache.Get("search:svc"); ok { - t.Fatal("cache should have been flushed") - } - - node, err := st.GetNode(ctx, "svc.Handler") - if err != nil { - t.Fatalf("get purged node: %v", err) - } - if node != nil { - t.Fatal("namespace graph should have been purged") - } - - otherNode, err := st.GetNode(context.Background(), "other.Handler") - if err != nil { - t.Fatalf("get untouched node: %v", err) - } - if otherNode == nil { - t.Fatal("out-of-namespace graph should remain") - } - - var svcCommunityCount, svcFlowCount, svcCommunityMembershipCount, svcFlowMembershipCount, otherCommunityCount, otherFlowCount int64 - if err := db.Model(&model.Community{}).Where("namespace = ?", "svc").Count(&svcCommunityCount).Error; err != nil { - t.Fatalf("count svc communities: %v", err) - } - if err := db.Model(&model.Flow{}).Where("namespace = ?", "svc").Count(&svcFlowCount).Error; err != nil { - t.Fatalf("count svc flows: %v", err) - } - if err := db.Model(&model.CommunityMembership{}).Where("community_id = ?", svcCommunity.ID).Count(&svcCommunityMembershipCount).Error; err != nil { - t.Fatalf("count svc community memberships: %v", err) - } - if err := db.Model(&model.FlowMembership{}).Where("flow_id = ?", svcFlow.ID).Count(&svcFlowMembershipCount).Error; err != nil { - t.Fatalf("count svc flow memberships: %v", err) - } - if err := db.Model(&model.Community{}).Where("namespace = ?", "other").Count(&otherCommunityCount).Error; err != nil { - t.Fatalf("count other communities: %v", err) - } - if err := db.Model(&model.Flow{}).Where("namespace = ?", "other").Count(&otherFlowCount).Error; err != nil { - t.Fatalf("count other flows: %v", err) - } - if svcCommunityCount != 0 { - t.Fatalf("namespace communities should have been purged, got %d", svcCommunityCount) - } - if svcFlowCount != 0 { - t.Fatalf("namespace flows should have been purged, got %d", svcFlowCount) - } - if svcCommunityMembershipCount != 0 { - t.Fatalf("namespace community memberships should have been purged, got %d", svcCommunityMembershipCount) - } - if svcFlowMembershipCount != 0 { - t.Fatalf("namespace flow memberships should have been purged, got %d", svcFlowMembershipCount) - } - if otherCommunityCount != 1 { - t.Fatalf("control community should remain, got %d", otherCommunityCount) - } - if otherFlowCount != 1 { - t.Fatalf("control flow should remain, got %d", otherFlowCount) - } -} - -func TestDeleteNamespace_PreservesFilesWhenDBPurgeFails(t *testing.T) { - namespaceRoot := t.TempDir() - h := &handlers{ - deps: &Deps{ - NamespaceRoot: namespaceRoot, - Store: &failDeleteGraphStore{err: errors.New("boom")}, - }, - } - - nsDir := filepath.Join(namespaceRoot, "svc") - if err := os.MkdirAll(nsDir, 0o755); err != nil { - t.Fatalf("mkdir namespace: %v", err) - } - if err := os.WriteFile(filepath.Join(nsDir, "file.go"), []byte("package svc"), 0o644); err != nil { - t.Fatalf("write namespace file: %v", err) - } - - req := makeCallToolRequest(t, map[string]any{"namespace": "svc"}) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - - if _, err := os.Stat(nsDir); err != nil { - t.Fatalf("namespace directory should remain on DB purge failure: %v", err) - } -} - -func TestDeleteNamespace_PurgesOrphanMembershipsAndSearchIndex(t *testing.T) { - namespaceRoot := t.TempDir() - ragRoot := t.TempDir() - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) - if err != nil { - t.Fatalf("open db: %v", err) - } - st := gormstore.New(db) - if err := st.AutoMigrate(); err != nil { - t.Fatalf("migrate store: %v", err) - } - if err := db.AutoMigrate(&model.SearchDocument{}, &model.Flow{}, &model.FlowMembership{}, &model.Community{}, &model.CommunityMembership{}); err != nil { - t.Fatalf("migrate extras: %v", err) - } - backend := &spySearchBackend{} - - h := &handlers{deps: &Deps{NamespaceRoot: namespaceRoot, RagIndexDir: ragRoot, Store: st, DB: db, SearchBackend: backend}} - nsDir := filepath.Join(namespaceRoot, "svc") - if err := os.MkdirAll(nsDir, 0o755); err != nil { - t.Fatalf("mkdir namespace: %v", err) - } - - svcCommunity := model.Community{Namespace: "svc", Key: "svc/core", Label: "svc/core", Strategy: "directory"} - svcFlow := model.Flow{Namespace: "svc", Name: "svc-flow"} - if err := db.Create(&svcCommunity).Error; err != nil { - t.Fatalf("seed svc community: %v", err) - } - if err := db.Create(&svcFlow).Error; err != nil { - t.Fatalf("seed svc flow: %v", err) - } - if err := db.Create(&model.CommunityMembership{CommunityID: svcCommunity.ID, NodeID: 424242}).Error; err != nil { - t.Fatalf("seed orphan community membership: %v", err) - } - if err := db.Create(&model.FlowMembership{Namespace: ctxns.DefaultNamespace, FlowID: svcFlow.ID, NodeID: 353535, Ordinal: 0}).Error; err != nil { - t.Fatalf("seed orphan flow membership: %v", err) - } - - req := makeCallToolRequest(t, map[string]any{"namespace": "svc"}) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultNotError(t, result) - - var communityMembershipCount, flowMembershipCount int64 - if err := db.Model(&model.CommunityMembership{}).Where("community_id = ?", svcCommunity.ID).Count(&communityMembershipCount).Error; err != nil { - t.Fatalf("count orphan community memberships: %v", err) - } - if err := db.Model(&model.FlowMembership{}).Where("flow_id = ?", svcFlow.ID).Count(&flowMembershipCount).Error; err != nil { - t.Fatalf("count orphan flow memberships: %v", err) - } - if communityMembershipCount != 0 { - t.Fatalf("expected orphan community memberships purged, got %d", communityMembershipCount) - } - if flowMembershipCount != 0 { - t.Fatalf("expected orphan flow memberships purged, got %d", flowMembershipCount) - } - if len(backend.purgeCalls) != 1 || backend.purgeCalls[0] != "svc" { - t.Fatalf("expected one purge call for svc, got %#v", backend.purgeCalls) - } - if backend.lastDB == nil || backend.lastDB == db { - t.Fatal("expected search purge to run inside namespace transaction handle") - } -} - -func TestDeleteNamespace_PreservesFilesWhenSearchPurgeFails(t *testing.T) { - namespaceRoot := t.TempDir() - ragRoot := t.TempDir() - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) - if err != nil { - t.Fatalf("open db: %v", err) - } - st := gormstore.New(db) - if err := st.AutoMigrate(); err != nil { - t.Fatalf("migrate store: %v", err) - } - ctx := ctxns.WithNamespace(t.Context(), "svc") - if err := st.UpsertNodes(ctx, []model.Node{{QualifiedName: "svc.Keep", Kind: model.NodeKindFunction, Name: "Keep", FilePath: "file.go", StartLine: 1, EndLine: 1, Language: "go"}}); err != nil { - t.Fatalf("seed node: %v", err) - } - backend := &spySearchBackend{purgeErr: errors.New("fts purge boom")} - h := &handlers{deps: &Deps{NamespaceRoot: namespaceRoot, RagIndexDir: ragRoot, Store: st, DB: db, SearchBackend: backend}} - - nsDir := filepath.Join(namespaceRoot, "svc") - if err := os.MkdirAll(nsDir, 0o755); err != nil { - t.Fatalf("mkdir namespace: %v", err) - } - - req := makeCallToolRequest(t, map[string]any{"namespace": "svc"}) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - - if _, err := os.Stat(nsDir); err != nil { - t.Fatalf("namespace directory should remain on search purge failure: %v", err) - } - if got, getErr := st.GetNode(ctx, "svc.Keep"); getErr != nil || got == nil { - t.Fatalf("namespace graph should remain on search purge failure, node=%+v err=%v", got, getErr) - } -} - -func TestDeleteNamespace_NotFound(t *testing.T) { - h, _ := namespaceHandlers(t) +func TestListNamespaces_ReturnsGraphNamespacesWithCounts(t *testing.T) { + deps := setupTestDeps(t) - req := makeCallToolRequest(t, map[string]any{ - "namespace": "nonexistent", + ctxA := ctxns.WithNamespace(context.Background(), "alpha") + deps.Store.UpsertNodes(ctxA, []model.Node{ + {QualifiedName: "a.F1", Kind: model.NodeKindFunction, Name: "F1", FilePath: "a.go", StartLine: 1, EndLine: 5, Language: "go"}, + {QualifiedName: "a.F2", Kind: model.NodeKindFunction, Name: "F2", FilePath: "a.go", StartLine: 6, EndLine: 9, Language: "go"}, }) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) -} - -func TestDeleteNamespace_PathTraversal(t *testing.T) { - h, _ := namespaceHandlers(t) - - for _, namespace := range []string{"../evil", ".", "..", "service/api"} { - t.Run(namespace, func(t *testing.T) { - req := makeCallToolRequest(t, map[string]any{ - "namespace": namespace, - }) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - }) - } -} - -func TestDeleteNamespace_NeverDeletesNamespaceRoot(t *testing.T) { - h, root := namespaceHandlers(t) - marker := filepath.Join(root, "marker.txt") - if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil { - t.Fatalf("write marker: %v", err) - } - - req := makeCallToolRequest(t, map[string]any{ - "namespace": ".", + ctxB := ctxns.WithNamespace(context.Background(), "beta") + deps.Store.UpsertNodes(ctxB, []model.Node{ + {QualifiedName: "b.G1", Kind: model.NodeKindFunction, Name: "G1", FilePath: "b.go", StartLine: 1, EndLine: 5, Language: "go"}, }) - result, err := h.deleteNamespace(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - if _, err := os.Stat(marker); err != nil { - t.Fatalf("namespace root marker should remain: %v", err) - } -} - -func TestUploadFile_RejectsNamespaceSymlinkEscape(t *testing.T) { - h, root := namespaceHandlers(t) - - outside := t.TempDir() - if err := os.Symlink(outside, filepath.Join(root, "svc")); err != nil { - t.Fatalf("symlink: %v", err) - } - - encoded := base64.StdEncoding.EncodeToString([]byte("malicious")) - req := makeCallToolRequest(t, map[string]any{ - "namespace": "svc", - "file_path": "docs/readme.md", - "content": encoded, - }) - - result, err := h.uploadFile(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - - if _, err := os.Stat(filepath.Join(outside, "docs", "readme.md")); !os.IsNotExist(err) { - t.Fatalf("expected no file to be written outside namespace root") - } -} - -func TestUploadFiles_RejectsIntermediateSymlinkEscape(t *testing.T) { - h, root := namespaceHandlers(t) - - nsDir := filepath.Join(root, "svc") - if err := os.MkdirAll(nsDir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - outside := t.TempDir() - if err := os.Symlink(outside, filepath.Join(nsDir, "link")); err != nil { - t.Fatalf("symlink: %v", err) - } - - content := base64.StdEncoding.EncodeToString([]byte("package main")) - filesJSON, _ := json.Marshal([]map[string]string{{ - "namespace": "svc", - "file_path": "link/main.go", - "content": content, - }}) - - req := makeCallToolRequest(t, map[string]any{"files": string(filesJSON)}) - result, err := h.uploadFiles(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - assertToolResultIsError(t, result) - - if _, err := os.Stat(filepath.Join(outside, "main.go")); !os.IsNotExist(err) { - t.Fatalf("expected no file to be written through symlink") + result := callTool(t, deps, "list_namespaces", map[string]any{}) + if result.IsError { + t.Fatalf("list_namespaces error: %s", getTextContent(result)) } -} - -func TestGetDocContent_RejectsNamespaceSymlinkEscape(t *testing.T) { - h, root := namespaceHandlers(t) - outside := t.TempDir() - secretPath := filepath.Join(outside, "secret.md") - if err := os.WriteFile(secretPath, []byte("secret"), 0o644); err != nil { - t.Fatalf("write secret: %v", err) + var resp struct { + Namespaces []struct { + Namespace string `json:"namespace"` + NodeCount int `json:"node_count"` + } `json:"namespaces"` + Count int `json:"count"` } - if err := os.Symlink(outside, filepath.Join(root, "svc")); err != nil { - t.Fatalf("symlink: %v", err) + if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { + t.Fatalf("expected JSON, got: %s", getTextContent(result)) } - req := makeCallToolRequest(t, map[string]any{ - "namespace": "svc", - "file_path": "secret.md", - }) - result, err := h.getDocContent(t.Context(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) + got := map[string]int{} + for _, ns := range resp.Namespaces { + got[ns.Namespace] = ns.NodeCount } - assertToolResultIsError(t, result) -} - -func makeCallToolRequest(t *testing.T, args map[string]any) mcp.CallToolRequest { - t.Helper() - req := mcp.CallToolRequest{} - req.Params.Arguments = args - return req -} - -func assertToolResultNotError(t *testing.T, result *mcp.CallToolResult) { - t.Helper() - if result.IsError { - t.Errorf("expected success, got error: %v", result.Content) + if got["alpha"] != 2 { + t.Fatalf("alpha node_count = %d, want 2 (resp: %s)", got["alpha"], getTextContent(result)) } -} - -func assertToolResultIsError(t *testing.T, result *mcp.CallToolResult) { - t.Helper() - if !result.IsError { - t.Errorf("expected error result, got success: %v", result.Content) + if got["beta"] != 1 { + t.Fatalf("beta node_count = %d, want 1 (resp: %s)", got["beta"], getTextContent(result)) } } diff --git a/internal/mcp/namespace_paths.go b/internal/mcp/namespace_paths.go new file mode 100644 index 0000000..b49ced0 --- /dev/null +++ b/internal/mcp/namespace_paths.go @@ -0,0 +1,81 @@ +// @index Namespace filesystem path resolution shared by namespace-scoped doc handlers. +package mcp + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/tae2089/code-context-graph/internal/pathutil" + "github.com/tae2089/code-context-graph/internal/safepath" +) + +// namespaceRoot returns the filesystem root used for namespace-scoped storage. +// @intent give namespace path resolution one shared root, defaulting to "namespaces". +func (h *handlers) namespaceRoot() string { + root := h.deps.NamespaceRoot + if root == "" { + root = "namespaces" + } + return root +} + +// validateNamespacePath rejects namespace and file inputs that could escape the namespace root. +// @intent keep namespace path validation in one place shared across handler files. +func validateNamespacePath(namespace, filePath string) error { + return pathutil.ValidateNamespacePath(namespace, filePath) +} + +// ensureNoSymlinkInPath walks each segment from root to relPath rejecting symlinks. +// @intent prevent symlink traversal from escaping the namespace root before a read. +func ensureNoSymlinkInPath(root, relPath string, allowMissingLeaf bool) (string, error) { + return safepath.EnsureNoSymlinkInPath(root, relPath, allowMissingLeaf) +} + +// safeNamespaceRoot returns the absolute, symlink-resolved namespace root, creating it if needed. +// @intent resolve namespace paths under a trusted, real filesystem location. +// @sideEffect creates the namespace root directory when it does not yet exist. +func (h *handlers) safeNamespaceRoot() (string, error) { + root := h.namespaceRoot() + absRoot, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("resolve namespace root: %w", err) + } + if err := os.MkdirAll(absRoot, 0o755); err != nil { + return "", fmt.Errorf("create namespace root: %w", err) + } + realRoot, err := filepath.EvalSymlinks(absRoot) + if err != nil { + return "", fmt.Errorf("resolve namespace root symlinks: %w", err) + } + return realRoot, nil +} + +// resolveNamespacePath resolves a namespace-relative file path under the trusted namespace root. +// @intent reject path traversal and symlink escapes before any namespace-scoped filesystem read. +// @param filePath relative path inside the namespace ("" returns the namespace dir). +// @param allowMissingLeaf when true, allow the leaf to not yet exist. +func (h *handlers) resolveNamespacePath(namespace, filePath string, allowMissingLeaf bool) (string, error) { + if err := validateNamespacePath(namespace, filePath); err != nil { + return "", err + } + root, err := h.safeNamespaceRoot() + if err != nil { + return "", err + } + wsDir, err := ensureNoSymlinkInPath(root, filepath.Clean(namespace), false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + wsDir = filepath.Join(root, filepath.Clean(namespace)) + } else { + return "", err + } + } + if filePath == "" { + return wsDir, nil + } + rel := filepath.Join(filepath.Clean(namespace), filepath.Clean(filePath)) + return ensureNoSymlinkInPath(root, rel, allowMissingLeaf) +} diff --git a/internal/mcp/namespace_test.go b/internal/mcp/namespace_test.go deleted file mode 100644 index 188ebc2..0000000 --- a/internal/mcp/namespace_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "gorm.io/gorm" - - "github.com/tae2089/code-context-graph/internal/analysis/query" - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" -) - -func seedNodeWithNamespace(t *testing.T, db *gorm.DB, ns, qn, kind, filePath string) { - t.Helper() - node := model.Node{ - Namespace: ns, - QualifiedName: qn, - Kind: model.NodeKind(kind), - Name: qn, - FilePath: filePath, - StartLine: 1, - EndLine: 10, - Language: "go", - } - if err := db.Create(&node).Error; err != nil { - t.Fatalf("seedNodeWithNamespace: %v", err) - } -} - -func TestMCPHandler_NamespaceFiltersGraph(t *testing.T) { - deps := setupTestDeps(t) - seedNodeWithNamespace(t, deps.DB, "ns-a", "pkg.Foo", "function", "a/foo.go") - seedNodeWithNamespace(t, deps.DB, "ns-b", "pkg.Foo", "function", "b/foo.go") - - result := callTool(t, deps, "get_node", map[string]any{"qualified_name": "pkg.Foo", "namespace": "ns-a"}) - if result.IsError { - t.Fatalf("get_node with namespace ns-a returned error: %v", getTextContent(result)) - } - text := getTextContent(result) - if !strings.Contains(text, "a/foo.go") || strings.Contains(text, "b/foo.go") { - t.Errorf("unexpected ns-a result: %s", text) - } - - result2 := callTool(t, deps, "get_node", map[string]any{"qualified_name": "pkg.Foo", "namespace": "ns-b"}) - if result2.IsError { - t.Fatalf("get_node with namespace ns-b returned error: %v", getTextContent(result2)) - } - text2 := getTextContent(result2) - if !strings.Contains(text2, "b/foo.go") { - t.Errorf("expected file_path 'b/foo.go' for ns-b, got: %s", text2) - } -} - -func TestMCPHandler_SearchWithNamespace(t *testing.T) { - deps := setupTestDeps(t) - seedNodeWithNamespace(t, deps.DB, "ns-a", "pkg.SearchMe", "function", "a/search.go") - seedNodeWithNamespace(t, deps.DB, "ns-b", "pkg.SearchMe", "function", "b/search.go") - - for _, ns := range []string{"ns-a", "ns-b"} { - var node model.Node - deps.DB.Where("namespace = ? AND qualified_name = ?", ns, "pkg.SearchMe").First(&node) - doc := model.SearchDocument{Namespace: ns, NodeID: node.ID, Content: "SearchMe function implementation", Language: "go"} - if err := deps.DB.Create(&doc).Error; err != nil { - t.Fatalf("create SearchDocument for %s: %v", ns, err) - } - } - for _, ns := range []string{"ns-a", "ns-b"} { - ctx := ctxns.WithNamespace(context.Background(), ns) - if err := deps.SearchBackend.Rebuild(ctx, deps.DB); err != nil { - t.Fatalf("rebuild search index for %s: %v", ns, err) - } - } - - result := callTool(t, deps, "search", map[string]any{"query": "SearchMe", "namespace": "ns-a"}) - if result.IsError { - t.Fatalf("search with namespace ns-a returned error: %v", getTextContent(result)) - } - text := getTextContent(result) - if !strings.Contains(text, "a/search.go") || strings.Contains(text, "b/search.go") { - t.Errorf("unexpected ns-a search result: %s", text) - } -} - -func TestMCPHandler_GraphWithNamespace(t *testing.T) { - deps := setupTestDeps(t) - seedNodeWithNamespace(t, deps.DB, "ns-a", "pkg.Alpha", "function", "a/alpha.go") - seedNodeWithNamespace(t, deps.DB, "ns-a", "pkg.Beta", "function", "a/beta.go") - seedNodeWithNamespace(t, deps.DB, "ns-b", "pkg.Gamma", "function", "b/gamma.go") - - result := callTool(t, deps, "list_graph_stats", map[string]any{"namespace": "ns-a"}) - if result.IsError { - t.Fatalf("list_graph_stats with namespace ns-a returned error: %v", getTextContent(result)) - } - text := getTextContent(result) - - var stats map[string]any - if err := json.Unmarshal([]byte(text), &stats); err != nil { - t.Fatalf("unmarshal stats: %v", err) - } - totalNodes, ok := stats["total_nodes"].(float64) - if !ok || int(totalNodes) != 2 { - t.Errorf("expected 2 nodes for ns-a, got %v", stats["total_nodes"]) - } -} - -func TestMCPHandler_ListGraphStats_EmptyNamespaceDoesNotIncludeOtherNamespaces(t *testing.T) { - deps := setupTestDeps(t) - seedNodeWithNamespace(t, deps.DB, "default", "pkg.Default", "function", "default.go") - seedNodeWithNamespace(t, deps.DB, "ns-a", "pkg.Other", "function", "other.go") - - result := callTool(t, deps, "list_graph_stats", map[string]any{}) - if result.IsError { - t.Fatalf("list_graph_stats returned error: %v", getTextContent(result)) - } - text := getTextContent(result) - - var stats map[string]any - if err := json.Unmarshal([]byte(text), &stats); err != nil { - t.Fatalf("unmarshal stats: %v", err) - } - totalNodes, ok := stats["total_nodes"].(float64) - if !ok || int(totalNodes) != 1 { - t.Fatalf("expected 1 default-namespace node, got %v", stats["total_nodes"]) - } -} - -func TestResolveNamespace_EmptyNamespaceFallsBackToDefault(t *testing.T) { - got := resolveNamespace(context.Background(), "") - if got != "default" { - t.Fatalf("resolveNamespace() = %q, want %q", got, "default") - } -} - -func TestMCPHandler_QueryWithNamespace(t *testing.T) { - deps := setupTestDeps(t) - deps.QueryService = query.New(deps.DB) - seedNodeWithNamespace(t, deps.DB, "ns-a", "pkg.Caller", "function", "a/caller.go") - seedNodeWithNamespace(t, deps.DB, "ns-a", "pkg.Callee", "function", "a/callee.go") - seedNodeWithNamespace(t, deps.DB, "ns-b", "pkg.Caller", "function", "b/caller.go") - - var callerA, calleeA model.Node - deps.DB.Where("namespace = ? AND qualified_name = ?", "ns-a", "pkg.Caller").First(&callerA) - deps.DB.Where("namespace = ? AND qualified_name = ?", "ns-a", "pkg.Callee").First(&calleeA) - edge := model.Edge{FromNodeID: callerA.ID, ToNodeID: calleeA.ID, Kind: "calls", Fingerprint: fmt.Sprintf("calls:%d:%d", callerA.ID, calleeA.ID)} - edge.Namespace = "ns-a" - if err := deps.DB.Create(&edge).Error; err != nil { - t.Fatalf("create edge: %v", err) - } - - result := callTool(t, deps, "query_graph", map[string]any{"pattern": "callees_of", "target": "pkg.Caller", "namespace": "ns-a"}) - if result.IsError { - t.Fatalf("query_graph with namespace ns-a returned error: %v", getTextContent(result)) - } - text := getTextContent(result) - if !strings.Contains(text, "pkg.Callee") { - t.Errorf("expected callee 'pkg.Callee' in ns-a results, got: %s", text) - } -} - -func TestMCPHandler_QueryWithNamespace_IncludesNamespaceEvidence(t *testing.T) { - deps := setupTestDeps(t) - deps.QueryService = query.New(deps.DB) - deps.NamespaceRoot = t.TempDir() - - ns := "ns-evidence" - namespaceDir := filepath.Join(deps.NamespaceRoot, ns) - if err := os.MkdirAll(namespaceDir, 0o755); err != nil { - t.Fatal(err) - } - runGit := func(args ...string) { - cmd := exec.Command("git", args...) - cmd.Dir = namespaceDir - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=ccg", "GIT_AUTHOR_EMAIL=ccg@example.com", "GIT_COMMITTER_NAME=ccg", "GIT_COMMITTER_EMAIL=ccg@example.com") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, out) - } - } - runGit("init") - runGit("add", "-A") - if err := os.WriteFile(filepath.Join(namespaceDir, "stub.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit("add", "stub.go") - runGit("commit", "-m", "init") - - seedNodeWithNamespace(t, deps.DB, ns, "pkg.Caller", "function", "a/caller.go") - seedNodeWithNamespace(t, deps.DB, ns, "pkg.Callee", "function", "a/callee.go") - caller, callee := model.Node{}, model.Node{} - deps.DB.Where("namespace = ? AND qualified_name = ?", ns, "pkg.Caller").First(&caller) - deps.DB.Where("namespace = ? AND qualified_name = ?", ns, "pkg.Callee").First(&callee) - if err := deps.DB.Create(&model.Edge{ - FromNodeID: caller.ID, - ToNodeID: callee.ID, - Kind: "calls", - Fingerprint: "calls:caller:callee", - Namespace: ns, - }).Error; err != nil { - t.Fatal(err) - } - - result := callTool(t, deps, "query_graph", map[string]any{"pattern": "callees_of", "target": "pkg.Caller", "namespace": ns}) - if result.IsError { - t.Fatalf("query_graph with namespace ns returned error: %v", getTextContent(result)) - } - - var body map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &body); err != nil { - t.Fatalf("expected JSON response: %v", err) - } - evidence, ok := body["evidence"].(map[string]any) - if !ok { - t.Fatal("expected evidence block in query_graph response") - } - if evidence["namespace"] != ns { - t.Fatalf("expected evidence namespace %q, got %v", ns, evidence["namespace"]) - } - if evidence["namespace_path"] != namespaceDir { - t.Fatalf("expected namespace path %q, got %v", namespaceDir, evidence["namespace_path"]) - } - gitInfo, ok := evidence["git"].(map[string]any) - if !ok { - t.Fatal("expected git evidence when namespace is git worktree") - } - if gitInfo["branch"] == nil { - t.Fatal("expected branch in git evidence") - } -} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 3cd47d1..5b7940b 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -29,6 +29,7 @@ func TestMCPServer_ListTools(t *testing.T) { "run_postprocess", "query_graph", "list_graph_stats", + "list_namespaces", "detect_changes", "get_affected_flows", "list_flows", @@ -36,12 +37,6 @@ func TestMCPServer_ListTools(t *testing.T) { "get_doc_content", "search_docs", "retrieve_docs", - "upload_file", - "list_namespaces", - "list_files", - "delete_file", - "upload_files", - "delete_namespace", "get_minimal_context", } @@ -72,8 +67,8 @@ func TestMCPServer_ListTools_Count(t *testing.T) { srv := NewServer(deps) tools := srv.ListTools() - if len(tools) != 26 { - t.Fatalf("expected 26 tools, got %d", len(tools)) + if len(tools) != 21 { + t.Fatalf("expected 21 tools, got %d", len(tools)) } } @@ -170,6 +165,7 @@ func TestMCPServer_ToolRequiredFlags(t *testing.T) { "run_postprocess": nil, "query_graph": {"pattern", "target"}, "list_graph_stats": nil, + "list_namespaces": nil, "detect_changes": {"repo_root"}, "get_affected_flows": {"repo_root"}, "list_flows": nil, @@ -177,12 +173,6 @@ func TestMCPServer_ToolRequiredFlags(t *testing.T) { "get_doc_content": {"file_path"}, "search_docs": {"query"}, "retrieve_docs": {"query"}, - "upload_file": {"namespace", "file_path", "content"}, - "list_namespaces": nil, - "list_files": {"namespace"}, - "delete_file": {"namespace", "file_path"}, - "upload_files": {"files"}, - "delete_namespace": {"namespace"}, "get_minimal_context": nil, } diff --git a/internal/mcp/tools_namespace.go b/internal/mcp/tools_namespace.go deleted file mode 100644 index 2648f06..0000000 --- a/internal/mcp/tools_namespace.go +++ /dev/null @@ -1,62 +0,0 @@ -// @index MCP tool registration for namespace file management. -package mcp - -import ( - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// namespaceTools registers file and namespace management tools for isolated namespaces. -// @intent expose upload, listing, and deletion workflows as one coherent MCP surface. -func namespaceTools(h *handlers) []server.ServerTool { - return []server.ServerTool{ - { - Tool: mcp.NewTool("upload_file", - mcp.WithDescription("Upload a file to a namespace. Content must be base64-encoded. Creates {namespace}/{file_path} on the server."), - mcp.WithString("namespace", mcp.Description("Namespace (e.g. service name)"), mcp.Required()), - mcp.WithString("file_path", mcp.Description("Relative file path within namespace (e.g. docs/readme.md)"), mcp.Required()), - mcp.WithString("content", mcp.Description("Base64-encoded file content"), mcp.Required()), - ), - Handler: h.uploadFile, - }, - { - Tool: mcp.NewTool("list_namespaces", - mcp.WithDescription("List all available namespaces"), - mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 50, max: 500)")), - mcp.WithNumber("offset", mcp.Description("Zero-based result offset for pagination (default: 0)")), - ), - Handler: h.listNamespaces, - }, - { - Tool: mcp.NewTool("list_files", - mcp.WithDescription("List all files in a namespace"), - mcp.WithString("namespace", mcp.Description("Namespace"), mcp.Required()), - mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 50, max: 500)")), - mcp.WithNumber("offset", mcp.Description("Zero-based result offset for pagination (default: 0)")), - ), - Handler: h.listFiles, - }, - { - Tool: mcp.NewTool("delete_file", - mcp.WithDescription("Delete a file from a namespace"), - mcp.WithString("namespace", mcp.Description("Namespace"), mcp.Required()), - mcp.WithString("file_path", mcp.Description("Relative file path within namespace"), mcp.Required()), - ), - Handler: h.deleteFile, - }, - { - Tool: mcp.NewTool("upload_files", - mcp.WithDescription("Upload multiple files to namespaces in a single call. The 'files' parameter is a JSON array of objects with namespace, file_path, and content (base64-encoded) fields."), - mcp.WithString("files", mcp.Description("JSON array of file entries: [{\"namespace\":\"...\",\"file_path\":\"...\",\"content\":\"base64...\"}]"), mcp.Required()), - ), - Handler: h.uploadFiles, - }, - { - Tool: mcp.NewTool("delete_namespace", - mcp.WithDescription("Delete an entire namespace and all its files"), - mcp.WithString("namespace", mcp.Description("Namespace to delete"), mcp.Required()), - ), - Handler: h.deleteNamespace, - }, - } -} diff --git a/internal/mcp/tools_query.go b/internal/mcp/tools_query.go index 5d8bac9..3fbfe14 100644 --- a/internal/mcp/tools_query.go +++ b/internal/mcp/tools_query.go @@ -58,5 +58,13 @@ func queryTools(h *handlers) []server.ServerTool { )...), Handler: h.listGraphStats, }, + { + Tool: mcp.NewTool("list_namespaces", + mcp.WithDescription("List namespaces that hold graph data with per-namespace node counts, for scoping cross-namespace queries"), + mcp.WithNumber("limit", mcp.Description("Maximum namespaces to return (default: 50)"), mcp.DefaultNumber(50)), + mcp.WithNumber("offset", mcp.Description("Zero-based result offset for pagination (default: 0)"), mcp.DefaultNumber(0)), + ), + Handler: h.listNamespaces, + }, } } diff --git a/internal/mcp/tools_register.go b/internal/mcp/tools_register.go index ebdb489..67e8e70 100644 --- a/internal/mcp/tools_register.go +++ b/internal/mcp/tools_register.go @@ -15,7 +15,6 @@ func registerTools(srv *server.MCPServer, h *handlers) { tools = append(tools, analysisTools(h)...) tools = append(tools, graphTools(h)...) tools = append(tools, docsTools(h)...) - tools = append(tools, namespaceTools(h)...) tools = append(tools, contextTools(h)...) srv.AddTools(tools...) } diff --git a/internal/namespacefs/workspace.go b/internal/namespacefs/workspace.go deleted file mode 100644 index cb91e29..0000000 --- a/internal/namespacefs/workspace.go +++ /dev/null @@ -1,464 +0,0 @@ -// @index Namespace filesystem service that owns path validation, traversal-safe resolution, and bulk upload/delete operations. -package namespacefs - -import ( - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/tae2089/code-context-graph/internal/safepath" -) - -// Default upload size limits applied when callers do not provide their own Limits. -// @intent expose stable defaults so MCP handlers and other callers stay aligned with historical limits. -const ( - DefaultMaxFileBytes = 10 << 20 // 10 MB - DefaultMaxRequestBytes = 50 << 20 // 50 MB - DefaultMaxTotalDecodedBytes = 20 << 20 // 20 MB -) - -// Limits bounds namespace upload payload sizes. -// @intent let MCP handlers and other callers configure upload caps while sharing the same enforcement code path. -type Limits struct { - MaxFileBytes int - MaxRequestBytes int - MaxTotalDecodedBytes int -} - -// DefaultLimits returns the namespace upload limits. -// @intent provide a single canonical default so handler code does not duplicate magic numbers. -func DefaultLimits() Limits { - return Limits{ - MaxFileBytes: DefaultMaxFileBytes, - MaxRequestBytes: DefaultMaxRequestBytes, - MaxTotalDecodedBytes: DefaultMaxTotalDecodedBytes, - } -} - -// Service performs namespace filesystem operations under a single root. -// @intent encapsulate namespace path validation, traversal protection, and atomic file writes for handlers and CLIs. -type Service struct { - // Root is the unresolved configured filesystem root for namespace storage. - Root string - // Limits bounds the size of accepted upload payloads. - Limits Limits -} - -// NewService constructs a Service for the supplied root. -// @intent build a namespace service with the canonical default limits applied when callers omit them. -func NewService(root string) *Service { - return &Service{Root: namespaceRootOrDefault(root), Limits: DefaultLimits()} -} - -// @intent collapse the empty-root convention into a single helper. -func namespaceRootOrDefault(root string) string { - if root == "" { - return "namespaces" - } - return root -} - -// SafeRoot returns the absolute, symlink-resolved namespace root, creating the root directory if needed. -// @intent ensure all namespace operations resolve paths under a trusted, real filesystem location. -// @sideEffect creates the namespace root directory when it does not yet exist. -func (s *Service) SafeRoot() (string, error) { - root := namespaceRootOrDefault(s.Root) - absRoot, err := filepath.Abs(root) - if err != nil { - return "", fmt.Errorf("resolve namespace root: %w", err) - } - if err := os.MkdirAll(absRoot, 0o755); err != nil { - return "", fmt.Errorf("create namespace root: %w", err) - } - realRoot, err := filepath.EvalSymlinks(absRoot) - if err != nil { - return "", fmt.Errorf("resolve namespace root symlinks: %w", err) - } - return realRoot, nil -} - -// ResolvePath resolves a namespace-relative file path under the trusted namespace root. -// @intent reject path traversal and symlink escapes before any filesystem mutation reaches the path. -// @param namespace single-segment namespace name. -// @param filePath relative path inside the namespace ("" returns the namespace dir). -// @param allowMissingLeaf when true, allow the leaf to not yet exist (used before atomic writes). -func (s *Service) ResolvePath(namespace, filePath string, allowMissingLeaf bool) (string, error) { - if err := ValidatePath(namespace, filePath); err != nil { - return "", err - } - root, err := s.SafeRoot() - if err != nil { - return "", err - } - wsDir, err := EnsureNoSymlinkInPath(root, filepath.Clean(namespace), false) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - wsDir = filepath.Join(root, filepath.Clean(namespace)) - } else { - return "", err - } - } - if filePath == "" { - return wsDir, nil - } - rel := filepath.Join(filepath.Clean(namespace), filepath.Clean(filePath)) - return EnsureNoSymlinkInPath(root, rel, allowMissingLeaf) -} - -// ValidatePath rejects empty, absolute, or traversal-bearing namespace and file inputs. -// @intent block path traversal attacks against namespace-scoped tools. -// @domainRule namespace must be a single safe path segment with no separators or parent-references. -func ValidatePath(namespace, filePath string) error { - if namespace == "" { - return fmt.Errorf("namespace must not be empty") - } - cleanWS := filepath.Clean(namespace) - if cleanWS == "." || cleanWS == ".." || filepath.IsAbs(cleanWS) || strings.HasPrefix(cleanWS, "..") || strings.ContainsAny(cleanWS, `/\\`) { - return fmt.Errorf("invalid namespace: must be a single safe name") - } - if filePath != "" { - cleanFP := filepath.Clean(filePath) - if filepath.IsAbs(cleanFP) || strings.HasPrefix(cleanFP, "..") { - return fmt.Errorf("invalid file_path: path traversal not allowed") - } - } - return nil -} - -// EnsureNoSymlinkInPath walks each path segment from root to relPath rejecting symlinks. -// @intent prevent symlink traversal from escaping the namespace root before any filesystem mutation. -// @param allowMissingLeaf when true, returns the joined path even when the leaf does not yet exist. -func EnsureNoSymlinkInPath(root, relPath string, allowMissingLeaf bool) (string, error) { - return safepath.EnsureNoSymlinkInPath(root, relPath, allowMissingLeaf) -} - -// SafeWrite atomically writes data to path using a temp file and rename, refusing to follow symlinks. -// @intent guarantee partial writes are never visible as the final file contents. -// @sideEffect creates a temp file in the destination directory and renames it into place. -func SafeWrite(path string, data []byte, perm os.FileMode) error { - tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp.*") - if err != nil { - return err - } - tmpPath := tmpFile.Name() - if err := tmpFile.Close(); err != nil { - _ = os.Remove(tmpPath) - return err - } - if err := writeFileNoFollow(tmpPath, data, perm); err != nil { - _ = os.Remove(tmpPath) - return err - } - if err := os.Rename(tmpPath, path); err != nil { - _ = os.Remove(tmpPath) - return err - } - return nil -} - -// UploadRequest describes one file payload destined for a namespace. -// @intent provide a typed input shared by single and bulk upload paths. -type UploadRequest struct { - Namespace string - FilePath string - // ContentBase64 is the base64-encoded payload as supplied by callers. - ContentBase64 string -} - -// UploadResult reports the outcome of one accepted file upload. -// @intent expose the decoded byte size so handlers can report it back to callers. -type UploadResult struct { - Namespace string - FilePath string - Size int -} - -// ValidationError is returned for caller-fixable input issues so handlers can map them to user errors. -// @intent distinguish bad-input errors from genuine I/O or system errors at the API boundary. -type ValidationError struct { - msg string -} - -// Error returns the validation message. -// @intent expose the caller-fixable validation message without wrapping it in transport-specific formatting. -// @return returns the original validation message stored on the error. -func (e *ValidationError) Error() string { return e.msg } - -// IsValidationError reports whether err is a ValidationError. -// @intent allow handlers to map validation failures to MCP user-error responses without leaking other errors. -func IsValidationError(err error) bool { - var v *ValidationError - return errors.As(err, &v) -} - -// newValidationError constructs a ValidationError for caller-fixable namespace input issues. -// @intent centralize creation of typed validation failures so handlers can recognize them consistently. -func newValidationError(msg string) *ValidationError { return &ValidationError{msg: msg} } - -// UploadFile validates, decodes, and atomically writes a single namespace file. -// @intent provide the canonical single-file upload primitive shared with bulk uploads. -// @domainRule decoded payloads cannot exceed the configured MaxFileBytes. -// @sideEffect creates the destination directory and writes the file atomically. -func (s *Service) UploadFile(req UploadRequest) (*UploadResult, error) { - prepared, err := s.prepareUpload(req, 0) - if err != nil { - return nil, err - } - if err := s.commitPrepared(prepared); err != nil { - return nil, err - } - return &UploadResult{Namespace: req.Namespace, FilePath: req.FilePath, Size: len(prepared.decoded)}, nil -} - -// preparedUpload holds validated upload data before it is written to disk. -// @intent split validation/decoding from filesystem mutation so bulk uploads can validate before committing files. -type preparedUpload struct { - req UploadRequest - decoded []byte - target string -} - -// prepareUpload validates one upload request, decodes its content, and resolves its destination path. -// @intent prepare a single upload for later commit while enforcing per-file and aggregate decoded-size limits. -// @param alreadyDecoded tracks the total decoded bytes accepted earlier in the same bulk request. -// @return returns a preparedUpload ready for commit when validation succeeds. -// @domainRule rejects missing fields, invalid paths, invalid base64, oversized files, and oversized aggregate payloads. -func (s *Service) prepareUpload(req UploadRequest, alreadyDecoded int) (*preparedUpload, error) { - limits := s.limitsOrDefault() - if req.Namespace == "" || req.FilePath == "" || req.ContentBase64 == "" { - return nil, newValidationError("namespace, file_path, and content are required") - } - if err := ValidatePath(req.Namespace, req.FilePath); err != nil { - return nil, &ValidationError{msg: err.Error()} - } - decoded, err := base64.StdEncoding.DecodeString(req.ContentBase64) - if err != nil { - return nil, newValidationError(fmt.Sprintf("invalid base64 content: %v", err)) - } - if len(decoded) > limits.MaxFileBytes { - return nil, newValidationError(fmt.Sprintf("file exceeds %d MB size limit", limits.MaxFileBytes>>20)) - } - if alreadyDecoded+len(decoded) > limits.MaxTotalDecodedBytes { - return nil, newValidationError(fmt.Sprintf("total decoded upload exceeds %d MB size limit", limits.MaxTotalDecodedBytes>>20)) - } - target, err := s.ResolvePath(req.Namespace, req.FilePath, true) - if err != nil { - return nil, fmt.Errorf("resolve namespace path: %w", err) - } - return &preparedUpload{req: req, decoded: decoded, target: target}, nil -} - -// commitPrepared creates parent directories, revalidates the path, and atomically writes one prepared upload. -// @intent separate filesystem mutation from validation so bulk uploads can fail fast before writing any file. -// @sideEffect creates directories and writes the target file atomically. -func (s *Service) commitPrepared(p *preparedUpload) error { - if err := os.MkdirAll(filepath.Dir(p.target), 0o755); err != nil { - return fmt.Errorf("create directory: %w", err) - } - if _, err := s.ResolvePath(p.req.Namespace, p.req.FilePath, true); err != nil { - return fmt.Errorf("revalidate namespace path: %w", err) - } - if err := SafeWrite(p.target, p.decoded, 0o644); err != nil { - return fmt.Errorf("write file: %w", err) - } - return nil -} - -// limitsOrDefault returns the configured limits with zero values replaced by namespace defaults. -// @intent ensure upload validation always runs with concrete byte ceilings even when callers omit custom limits. -// @return returns a Limits value whose file, request, and aggregate byte caps are all populated. -func (s *Service) limitsOrDefault() Limits { - l := s.Limits - if l.MaxFileBytes == 0 { - l.MaxFileBytes = DefaultMaxFileBytes - } - if l.MaxRequestBytes == 0 { - l.MaxRequestBytes = DefaultMaxRequestBytes - } - if l.MaxTotalDecodedBytes == 0 { - l.MaxTotalDecodedBytes = DefaultMaxTotalDecodedBytes - } - return l -} - -// BulkEntry is the JSON envelope used by bulk uploads. -// @intent mirror the MCP request shape so handlers can deserialize directly. -type BulkEntry struct { - Namespace string `json:"namespace"` - FilePath string `json:"file_path"` - Content string `json:"content"` -} - -// BulkEntryError reports the index of the failing entry alongside the underlying error. -// @intent let handlers prefix MCP error messages with the offending entry index without re-implementing iteration. -type BulkEntryError struct { - Index int - Err error -} - -// Error formats the failing bulk entry index with the underlying error text. -// @intent preserve a user-facing error string that points callers to the exact bad entry. -// @return returns an error string prefixed with the failing entry index. -func (e *BulkEntryError) Error() string { return fmt.Sprintf("entry %d: %v", e.Index, e.Err) } - -// Unwrap returns the underlying entry error. -// @intent allow callers to inspect the original validation or filesystem failure that broke a bulk upload. -// @return returns the underlying entry error for errors.Is and errors.As checks. -func (e *BulkEntryError) Unwrap() error { return e.Err } - -// UploadFiles parses a bulk upload JSON payload, validates every entry, and writes them sequentially. -// @intent batch namespace uploads with the same validation rules as single uploads while keeping atomicity per file. -// @domainRule rejects the request when raw bytes exceed MaxRequestBytes, when the array is empty, or when any entry fails validation. -// @sideEffect creates directories and writes each accepted file atomically. -func (s *Service) UploadFiles(rawJSON string) ([]UploadResult, error) { - limits := s.limitsOrDefault() - if len(rawJSON) > limits.MaxRequestBytes { - return nil, newValidationError(fmt.Sprintf("total upload request exceeds %d MB size limit", limits.MaxRequestBytes>>20)) - } - var entries []BulkEntry - if err := json.Unmarshal([]byte(rawJSON), &entries); err != nil { - return nil, newValidationError(fmt.Sprintf("invalid files JSON: %v", err)) - } - if len(entries) == 0 { - return nil, newValidationError("files array must not be empty") - } - - prepared := make([]*preparedUpload, 0, len(entries)) - totalDecoded := 0 - for i, e := range entries { - req := UploadRequest{Namespace: e.Namespace, FilePath: e.FilePath, ContentBase64: e.Content} - p, err := s.prepareUpload(req, totalDecoded) - if err != nil { - return nil, &BulkEntryError{Index: i, Err: err} - } - totalDecoded += len(p.decoded) - prepared = append(prepared, p) - } - - results := make([]UploadResult, 0, len(prepared)) - for i, p := range prepared { - if err := s.commitPrepared(p); err != nil { - return nil, &BulkEntryError{Index: i, Err: err} - } - results = append(results, UploadResult{Namespace: p.req.Namespace, FilePath: p.req.FilePath, Size: len(p.decoded)}) - } - return results, nil -} - -// ListNamespaces returns the alphabetically sorted directories under the namespace root. -// @intent expose available namespaces so callers can pick an upload target. -// @sideEffect reads the namespace root directory. -func (s *Service) ListNamespaces() ([]string, error) { - entries, err := os.ReadDir(namespaceRootOrDefault(s.Root)) - if err != nil { - if os.IsNotExist(err) { - return []string{}, nil - } - return nil, fmt.Errorf("read namespace root: %w", err) - } - out := make([]string, 0, len(entries)) - for _, e := range entries { - if e.IsDir() { - out = append(out, e.Name()) - } - } - slices.Sort(out) - return out, nil -} - -// ListFiles walks the namespace directory returning relative file paths sorted alphabetically. -// @intent surface the current file inventory of a namespace for clients that need to plan further operations. -// @sideEffect performs a recursive filesystem walk that skips symlinks defensively. -func (s *Service) ListFiles(namespace string) ([]string, error) { - if err := ValidatePath(namespace, ""); err != nil { - return nil, &ValidationError{msg: err.Error()} - } - wsDir, err := s.ResolvePath(namespace, "", false) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return []string{}, nil - } - return nil, fmt.Errorf("resolve namespace path: %w", err) - } - var files []string - err = filepath.Walk(wsDir, func(fp string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return nil - } - if info.Mode()&os.ModeSymlink != 0 { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - if info.IsDir() { - return nil - } - rel, relErr := filepath.Rel(wsDir, fp) - if relErr != nil { - return nil - } - files = append(files, rel) - return nil - }) - if err != nil { - return nil, fmt.Errorf("walk namespace: %w", err) - } - if files == nil { - files = []string{} - } - slices.Sort(files) - return files, nil -} - -// DeleteFile removes a single namespaced file after path validation. -// @intent allow targeted cleanup of namespace contents without exposing raw filesystem paths. -// @sideEffect deletes the file from the filesystem. -func (s *Service) DeleteFile(namespace, filePath string) error { - if err := ValidatePath(namespace, filePath); err != nil { - return &ValidationError{msg: err.Error()} - } - target, err := s.ResolvePath(namespace, filePath, false) - if err != nil { - return fmt.Errorf("resolve namespace path: %w", err) - } - if _, err := os.Stat(target); os.IsNotExist(err) { - return &ValidationError{msg: fmt.Sprintf("file %q not found in namespace %q", filePath, namespace)} - } - if err := os.Remove(target); err != nil { - return fmt.Errorf("delete file: %w", err) - } - return nil -} - -// ResolveExistingNamespace returns the validated namespace directory ensuring it currently exists. -// @intent let callers stat or remove an entire namespace without duplicating validation logic. -func (s *Service) ResolveExistingNamespace(namespace string) (string, error) { - if err := ValidatePath(namespace, ""); err != nil { - return "", &ValidationError{msg: err.Error()} - } - wsDir, err := s.ResolvePath(namespace, "", false) - if err != nil { - return "", fmt.Errorf("resolve namespace path: %w", err) - } - if _, err := os.Stat(wsDir); os.IsNotExist(err) { - return "", &ValidationError{msg: fmt.Sprintf("namespace %q not found", namespace)} - } - return wsDir, nil -} - -// RemoveTree recursively deletes a previously resolved namespace directory. -// @intent provide the final filesystem step of namespace deletion after upstream cleanup succeeds. -// @sideEffect recursively deletes the namespace directory tree. -func (s *Service) RemoveTree(wsDir string) error { - if err := os.RemoveAll(wsDir); err != nil { - return fmt.Errorf("delete namespace: %w", err) - } - return nil -} diff --git a/internal/namespacefs/write_unix.go b/internal/namespacefs/write_unix.go deleted file mode 100644 index 59b591e..0000000 --- a/internal/namespacefs/write_unix.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !windows - -package namespacefs - -import ( - "os" - "syscall" -) - -// writeFileNoFollow writes a file without following symlinks on Unix. -// @intent prevent namespace upload paths from escaping the allowed root through symlink traversal. -// @sideEffect creates or truncates the target file and fsyncs it to disk. -func writeFileNoFollow(path string, data []byte, perm os.FileMode) error { - fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_TRUNC|syscall.O_NOFOLLOW, uint32(perm)) - if err != nil { - return err - } - file := os.NewFile(uintptr(fd), path) - defer file.Close() - if _, err := file.Write(data); err != nil { - return err - } - return file.Sync() -} diff --git a/internal/namespacefs/write_windows.go b/internal/namespacefs/write_windows.go deleted file mode 100644 index f7347dd..0000000 --- a/internal/namespacefs/write_windows.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build windows - -package namespacefs - -import "os" - -// writeFileNoFollow writes a file using the Windows-safe fallback path where O_NOFOLLOW is unavailable. -// @intent provide the same namespace write entry point on Windows where O_NOFOLLOW is unavailable. -// @sideEffect truncates or creates the target file. -func writeFileNoFollow(path string, data []byte, perm os.FileMode) error { - return os.WriteFile(path, data, perm) -} diff --git a/internal/pathutil/namespace.go b/internal/pathutil/namespace.go new file mode 100644 index 0000000..63c043a --- /dev/null +++ b/internal/pathutil/namespace.go @@ -0,0 +1,29 @@ +package pathutil + +import ( + "fmt" + "path/filepath" + "strings" +) + +// ValidateNamespacePath rejects namespace and file inputs that could escape the namespace root. +// @intent block path traversal against namespace-scoped filesystem reads. +// @domainRule namespace must be a single safe segment (no "/", "\\", "..", ".", or absolute path); +// filePath, when set, must be relative and free of parent-references. +// @param filePath relative path inside the namespace; "" validates the namespace name alone. +func ValidateNamespacePath(namespace, filePath string) error { + if namespace == "" { + return fmt.Errorf("namespace must not be empty") + } + cleanNS := filepath.Clean(namespace) + if cleanNS == "." || cleanNS == ".." || filepath.IsAbs(cleanNS) || strings.HasPrefix(cleanNS, "..") || strings.ContainsAny(cleanNS, `/\`) { + return fmt.Errorf("invalid namespace: must be a single safe name") + } + if filePath != "" { + cleanFP := filepath.Clean(filePath) + if filepath.IsAbs(cleanFP) || strings.HasPrefix(cleanFP, "..") { + return fmt.Errorf("invalid file_path: path traversal not allowed") + } + } + return nil +} diff --git a/internal/pathutil/namespace_test.go b/internal/pathutil/namespace_test.go new file mode 100644 index 0000000..2df09c9 --- /dev/null +++ b/internal/pathutil/namespace_test.go @@ -0,0 +1,40 @@ +package pathutil_test + +import ( + "testing" + + "github.com/tae2089/code-context-graph/internal/pathutil" +) + +func TestValidateNamespacePath(t *testing.T) { + cases := []struct { + name string + namespace string + filePath string + wantErr bool + }{ + {"simple name", "api", "", false}, + {"with dash", "org-api", "", false}, + {"name + relative file", "api", "docs/x.md", false}, + {"empty namespace", "", "", true}, + {"dot", ".", "", true}, + {"dotdot", "..", "", true}, + {"traversal prefix", "../etc", "", true}, + {"forward slash in name", "a/b", "", true}, + {"back slash in name", `a\b`, "", true}, + {"absolute namespace", "/etc", "", true}, + {"absolute file", "api", "/etc/passwd", true}, + {"traversal file", "api", "../secret", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := pathutil.ValidateNamespacePath(tc.namespace, tc.filePath) + if tc.wantErr && err == nil { + t.Fatalf("ValidateNamespacePath(%q, %q) = nil, want error", tc.namespace, tc.filePath) + } + if !tc.wantErr && err != nil { + t.Fatalf("ValidateNamespacePath(%q, %q) = %v, want nil", tc.namespace, tc.filePath, err) + } + }) + } +} diff --git a/internal/wikiserver/server.go b/internal/wikiserver/server.go index 8849b09..de77cc5 100644 --- a/internal/wikiserver/server.go +++ b/internal/wikiserver/server.go @@ -21,7 +21,7 @@ import ( "github.com/tae2089/code-context-graph/internal/ccgref" "github.com/tae2089/code-context-graph/internal/ctxns" "github.com/tae2089/code-context-graph/internal/model" - nsfs "github.com/tae2089/code-context-graph/internal/namespacefs" + "github.com/tae2089/code-context-graph/internal/pathutil" "github.com/tae2089/code-context-graph/internal/ragindex" "github.com/tae2089/code-context-graph/internal/retrieval" storesearch "github.com/tae2089/code-context-graph/internal/store/search" @@ -1152,7 +1152,7 @@ func validateNamespace(namespace string) error { if namespace == ctxns.DefaultNamespace { return nil } - return nsfs.ValidatePath(namespace, "") + return pathutil.ValidateNamespacePath(namespace, "") } // @intent parse the optional edge_kinds filter for the Wiki graph API. diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 86cde24..e8f1e22 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -740,46 +740,6 @@ else warn "MCP session unavailable under local debug override — skipping Phase 12" fi -# ── Phase 13: Namespace/File management tools ── -if [ -n "$MCP_SESSION" ]; then - info "Phase 13: Testing Namespace/File management tools..." - - TEST_NS="e2e-test-ns" - B64_CONTENT=$(echo -n "package main\nfunc hello() {}" | base64) - - # upload_file - RESP=$(mcp_call "upload_file" "{\"namespace\":\"${TEST_NS}\",\"file_path\":\"hello.go\",\"content\":\"${B64_CONTENT}\"}") - assert_mcp_ok "upload_file" "$RESP" - assert_mcp_contains "upload_file" "$RESP" "ok" - - # upload_files — batch upload - B64_A=$(echo -n "file_a content" | base64) - B64_B=$(echo -n "file_b content" | base64) - FILES_JSON="[{\"namespace\":\"${TEST_NS}\",\"file_path\":\"a.txt\",\"content\":\"${B64_A}\"},{\"namespace\":\"${TEST_NS}\",\"file_path\":\"b.txt\",\"content\":\"${B64_B}\"}]" - RESP=$(mcp_call "upload_files" "{\"files\":$(echo "$FILES_JSON" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))')}") - assert_mcp_ok "upload_files" "$RESP" - assert_mcp_contains "upload_files" "$RESP" "uploaded" - - # list_files — should see uploaded files - RESP=$(mcp_call "list_files" "{\"namespace\":\"${TEST_NS}\"}") - assert_mcp_ok "list_files" "$RESP" - assert_mcp_contains "list_files" "$RESP" "hello.go" - - # delete_file — remove a.txt - RESP=$(mcp_call "delete_file" "{\"namespace\":\"${TEST_NS}\",\"file_path\":\"a.txt\"}") - assert_mcp_ok "delete_file" "$RESP" - assert_mcp_contains "delete_file" "$RESP" "deleted" - - # delete_namespace — remove entire test namespace - RESP=$(mcp_call "delete_namespace" "{\"namespace\":\"${TEST_NS}\"}") - assert_mcp_ok "delete_namespace" "$RESP" - assert_mcp_contains "delete_namespace" "$RESP" "deleted" - - info "Phase 13 complete ✅" -else - warn "MCP session unavailable under local debug override — skipping Phase 13" -fi - # ── Phase 14: Docs/RAG tools (search_docs, get_doc_content) ── if [ -n "$MCP_SESSION" ]; then info "Phase 14: Testing Docs/RAG tools..." @@ -807,11 +767,9 @@ print(find_file(tree)) " 2>/dev/null) || DOC_PATH="" if [ -n "$DOC_PATH" ]; then - DOC_B64=$(printf '# Integration Doc\n\nGenerated doc content for get_doc_content.\n' | base64 | tr -d '\n') - mcp_call "upload_file" "{\"namespace\":\"${REPO_NAME}\",\"file_path\":\"${DOC_PATH}\",\"content\":\"${DOC_B64}\"}" >/dev/null + # Read a doc generated by the RAG builder in Phase 8 (no upload path anymore). RESP=$(mcp_call "get_doc_content" "{\"file_path\":\"${DOC_PATH}\",\"namespace\":\"${REPO_NAME}\"}") assert_mcp_ok "get_doc_content" "$RESP" - assert_mcp_contains "get_doc_content" "$RESP" "Generated doc content" info "✅ get_doc_content: read ${DOC_PATH}" else warn "No doc file found in RAG tree — skipping get_doc_content read test" @@ -825,46 +783,9 @@ else warn "MCP session unavailable under local debug override — skipping Phase 14" fi -# ── Phase 15: Build tools (parse_project, build_or_update_graph) ── -if [ -n "$MCP_SESSION" ]; then - info "Phase 15: Testing Build tools..." - - # upload a small Go project to a test namespace, then parse and build - BUILD_NS="e2e-build-ns" - B64_MAIN=$(printf 'package main\n\nfunc Run() string {\n\treturn "running"\n}\n' | base64) - B64_UTIL=$(printf 'package main\n\nfunc Helper() int {\n\treturn 42\n}\n' | base64) - - mcp_call "upload_file" "{\"namespace\":\"${BUILD_NS}\",\"file_path\":\"main.go\",\"content\":\"${B64_MAIN}\"}" >/dev/null - mcp_call "upload_file" "{\"namespace\":\"${BUILD_NS}\",\"file_path\":\"util.go\",\"content\":\"${B64_UTIL}\"}" >/dev/null - - # parse_project — parse the uploaded namespace - NS_ROOT=$(compose exec -T ccg printenv NAMESPACE_ROOT 2>/dev/null | tr -d '\r') || NS_ROOT="" - if [ -z "$NS_ROOT" ]; then - NS_ROOT="namespaces" - fi - PARSE_PATH="${NS_ROOT}/${BUILD_NS}" - - RESP=$(mcp_call "parse_project" "{\"path\":\"${PARSE_PATH}\",\"namespace\":\"${BUILD_NS}\"}") - assert_mcp_ok "parse_project" "$RESP" - assert_mcp_contains "parse_project" "$RESP" "parsed" - - # build_or_update_graph — full rebuild with postprocess - RESP=$(mcp_call "build_or_update_graph" "{\"path\":\"${PARSE_PATH}\",\"full_rebuild\":true,\"postprocess\":\"full\",\"namespace\":\"${BUILD_NS}\"}") - assert_mcp_ok "build_or_update_graph" "$RESP" - assert_mcp_contains "build_or_update_graph" "$RESP" "ok" - - # Verify the graph was built via list_graph_stats - RESP=$(mcp_call "list_graph_stats" "{\"namespace\":\"${BUILD_NS}\"}") - assert_mcp_ok "list_graph_stats(build)" "$RESP" - assert_mcp_gt0 "list_graph_stats(build)" "$RESP" "total_nodes" - - # Cleanup: delete build namespace - mcp_call "delete_namespace" "{\"namespace\":\"${BUILD_NS}\"}" >/dev/null - - info "Phase 15 complete ✅" -else - warn "MCP session unavailable under local debug override — skipping Phase 15" -fi +# Note: the former Phase 15 (MCP parse_project/build_or_update_graph via uploaded +# namespace files) was removed with the file-upload tools. Graph build is covered +# by the webhook clone→build pipeline earlier in this script. # ── Summary ── TOOLS_TESTED=0