Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6ed87cdcf | |||
| f059b6f458 | |||
| 4b350fe967 | |||
| 1d770b45c9 | |||
| fd473c298c | |||
| 44f75cf6e0 | |||
| 01da7b9736 | |||
| 57a46d9bf8 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
17
README.md
17
README.md
@@ -60,6 +60,7 @@ Ask Claude things like:
|
||||
- "Trigger the build.yml workflow on the main branch"
|
||||
- "What workflows are available in myorg/myrepo?"
|
||||
- "Show me the build.yml workflow file"
|
||||
- "Validate the build.yml workflow in myorg/myrepo"
|
||||
- "What artifacts were produced by run #77?"
|
||||
- "What's the queue depth for each runner label?"
|
||||
- "Approve the workflow run from the fork PR"
|
||||
@@ -68,6 +69,10 @@ Ask Claude things like:
|
||||
- "Diagnose why job 456 failed"
|
||||
- "What secrets are available for myorg/myrepo?"
|
||||
- "List all NuGet packages for myorg"
|
||||
- "What are the package defaults for myorg?"
|
||||
- "List all repos for myorg"
|
||||
- "List open issues for gitcaddy/server"
|
||||
- "Show me issue #42 in myorg/myrepo"
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -83,8 +88,9 @@ Ask Claude things like:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_workflows` | List available workflow files in a repository |
|
||||
| `list_workflows` | List available workflow files in a repository (includes validation status) |
|
||||
| `get_workflow_file` | Get the YAML content of a workflow file |
|
||||
| `validate_workflow` | Validate a workflow YAML file for parse errors (from repo or raw content) |
|
||||
| `list_workflow_runs` | List workflow runs for a repository |
|
||||
| `get_workflow_run` | Get run details with all jobs |
|
||||
| `get_job_logs` | Get logs from a specific job (auto-extracts errors for failed jobs) |
|
||||
@@ -100,6 +106,14 @@ Ask Claude things like:
|
||||
| `list_artifacts` | List artifacts from a workflow run |
|
||||
| `get_artifact_download_url` | Get the download URL for a specific artifact |
|
||||
|
||||
### Repository & Issue Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_repos` | List repositories for an owner (org or user) |
|
||||
| `list_issues` | List issues for a repository with pagination and state filtering |
|
||||
| `get_issue` | Get issue details including body content and comments |
|
||||
|
||||
### Release & Package Tools
|
||||
|
||||
| Tool | Description |
|
||||
@@ -108,6 +122,7 @@ Ask Claude things like:
|
||||
| `get_release` | Get release details with all assets |
|
||||
| `list_secrets` | List secret names and descriptions (not values) for global, org, and repo scopes |
|
||||
| `list_packages` | List packages for an owner or globally with version and visibility info |
|
||||
| `get_package_defaults` | Get preconfigured package defaults (authors, company, copyright, icon, URLs) for an org |
|
||||
|
||||
### AI Learning Tools
|
||||
|
||||
|
||||
108
main.go
108
main.go
@@ -69,10 +69,11 @@ func main() {
|
||||
debugLog("Connecting to: %s", giteaURL)
|
||||
|
||||
// Read JSON-RPC messages from stdin, forward to Gitea, write responses to stdout
|
||||
// Supports both raw JSON lines and Content-Length framed messages (Claude Code uses framing)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
line, err := readMessage(reader)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
debugLog("EOF received, exiting")
|
||||
@@ -89,10 +90,22 @@ func main() {
|
||||
|
||||
debugLog("Received: %s", string(line))
|
||||
|
||||
// JSON-RPC notifications (no "id" field) must NEVER receive a response.
|
||||
// The MCP spec defines notifications/initialized, notifications/cancelled,
|
||||
// notifications/progress, etc. — these are fire-and-forget. Even if the
|
||||
// remote gitcaddy server returns an error for an unknown notification, we
|
||||
// must suppress it on the way back to the client (Claude Code), otherwise
|
||||
// the client sees an unexpected response and fails the handshake.
|
||||
isNotification := isJsonRpcNotification(line)
|
||||
|
||||
// Forward to Gitea's MCP endpoint
|
||||
response, err := forwardToGitea(line)
|
||||
if err != nil {
|
||||
debugLog("Forward error: %v", err)
|
||||
if isNotification {
|
||||
// Notification — no response allowed, just swallow.
|
||||
continue
|
||||
}
|
||||
// Send error response
|
||||
errorResp := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
@@ -109,11 +122,30 @@ func main() {
|
||||
|
||||
debugLog("Response: %s", string(response))
|
||||
|
||||
// Write response to stdout
|
||||
fmt.Println(string(response))
|
||||
if isNotification {
|
||||
// Notification — never respond, even if the remote sent something.
|
||||
debugLog("Suppressing response to notification")
|
||||
continue
|
||||
}
|
||||
|
||||
// Write response to stdout as newline-delimited JSON
|
||||
writeFramed(response)
|
||||
}
|
||||
}
|
||||
|
||||
// isJsonRpcNotification reports whether a raw JSON-RPC message is a notification
|
||||
// (a request without an "id" field). Per JSON-RPC 2.0, servers MUST NOT reply to
|
||||
// notifications.
|
||||
func isJsonRpcNotification(raw []byte) bool {
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return false
|
||||
}
|
||||
_, hasId := probe["id"]
|
||||
_, hasMethod := probe["method"]
|
||||
return hasMethod && !hasId
|
||||
}
|
||||
|
||||
func forwardToGitea(request []byte) ([]byte, error) {
|
||||
mcpURL := giteaURL + "/api/v2/mcp"
|
||||
|
||||
@@ -147,9 +179,77 @@ func forwardToGitea(request []byte) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// readMessage reads a JSON-RPC message from stdin.
|
||||
// Handles both raw JSON lines and Content-Length framed messages.
|
||||
// Claude Code sends: Content-Length: N\r\n\r\n{json}
|
||||
func readMessage(reader *bufio.Reader) ([]byte, error) {
|
||||
// Peek at first bytes to detect format
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if len(trimmed) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// If it starts with '{', it's a raw JSON line
|
||||
if trimmed[0] == '{' {
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// If it starts with "Content-Length:", read the framed message
|
||||
if bytes.HasPrefix(bytes.ToLower(trimmed), []byte("content-length:")) {
|
||||
// Parse content length
|
||||
parts := bytes.SplitN(trimmed, []byte(":"), 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
lengthStr := bytes.TrimSpace(parts[1])
|
||||
var contentLength int
|
||||
fmt.Sscanf(string(lengthStr), "%d", &contentLength)
|
||||
if contentLength <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read until empty line (end of headers)
|
||||
for {
|
||||
headerLine, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bytes.TrimSpace(headerLine)) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Read exactly contentLength bytes
|
||||
body := make([]byte, contentLength)
|
||||
_, err := io.ReadFull(reader, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Unknown line, skip
|
||||
debugLog("Skipping unknown line: %s", string(trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
func writeResponse(resp interface{}) {
|
||||
data, _ := json.Marshal(resp)
|
||||
fmt.Println(string(data))
|
||||
writeFramed(data)
|
||||
}
|
||||
|
||||
func writeFramed(data []byte) {
|
||||
// MCP stdio transport uses newline-delimited JSON (NDJSON), not LSP-style
|
||||
// Content-Length framing. Claude Code and other MCP clients reject
|
||||
// Content-Length headers on stdio, so we emit one JSON object per line.
|
||||
fmt.Fprintf(os.Stdout, "%s\n", data)
|
||||
}
|
||||
|
||||
func debugLog(format string, args ...interface{}) {
|
||||
|
||||
Reference in New Issue
Block a user