feat(routing): project_create MCP tool — gitea-first new-project pipeline (#10)
Adds the project_create tool to the routing pod that automates the
"new project" bootstrap end-to-end from claude.ai. Gitea-first
architecture: GitHub receives the repo only via push-mirror, never
via a direct GitHub API call from this server.
Four sequential calls to the gitea-mcp server (configured via
GITEA_MCP_URL):
1. create_project_from_template — Gitea repo from
template-go-{agent,web} per the 'stack' arg
2. repo_mirror_push (action=add) — push-mirror to
github.com/<GITHUB_OWNER>/<name>.git, interval 8h, sync_on_commit
3. file_write_branch — k3s/staging/<name>/namespace.yaml committed
on a staging/<name> branch in the infra repo
4. issue_create — experiment brief (hypothesis + description + stack
+ provisioning log) on the new repo, returns the issue_url
Returns gitea_url, github_url, issue_url, next_steps. The next_steps
string is the exact shell sequence the operator runs locally to
clone, scaffold via local-dev 'task new-project', and push.
Idempotency: create_project_from_template + repo_mirror_push +
file_write_branch all return JSON-RPC code -32003 (Conflict) when
their target already exists; the orchestrator swallows the conflict
and continues. Re-running on an existing repo restates the brief in
a fresh issue.
Error handling: on any non-conflict downstream failure the response
returns {reached: ["<step>",...], failed_step: "<step>"} alongside
a JSON-RPC error. No rollback — partial state stays so the operator
can resume manually.
New env vars (all optional except GITEA_MCP_URL):
GITEA_MCP_URL enables the tool
GITEA_MCP_TOKEN bearer auth for gitea-mcp
GITEA_OWNER default mathias
GITHUB_OWNER default mathiasb
INFRA_REPO default infra
GITHUB_PAT repo scope, used as mirror remote_password; never logged
Without GITEA_MCP_URL set, the tool is not registered and the
routing pod starts normally (degrades open).
internal/mcpclient/: new minimal JSON-RPC tools/call client with
bearer auth, used by project_create. Unwraps MCP's
content[0].text envelope and surfaces typed errors via mcpclient.Error.
Tests: table-driven against an httptest fake gitea-mcp covering happy
path (4-step success + correct PATCH-style arg shapes), idempotent
repo-exists, mirror failure (partial-success response with reached=
[create_repo] + failed_step=mirror), infra-commit failure (reached up
to mirror + failed_step=infra_commit), and validation errors.
Closes #10
This commit is contained in:
135
internal/mcpclient/client.go
Normal file
135
internal/mcpclient/client.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Package mcpclient is a minimal JSON-RPC over HTTP client for talking to
|
||||
// MCP servers from inside hyperguild components. It only implements
|
||||
// `tools/call` because that's all consumer skills need today.
|
||||
package mcpclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client calls an MCP server over Streamable HTTP / JSON-RPC.
|
||||
type Client struct {
|
||||
url string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client. token may be empty for unauthenticated servers.
|
||||
func New(url, token string) *Client {
|
||||
return &Client{
|
||||
url: url,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient overrides the underlying HTTP client (test injection).
|
||||
func (c *Client) WithHTTPClient(h *http.Client) *Client {
|
||||
c.http = h
|
||||
return c
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Error is returned when the remote MCP server signals a typed failure.
|
||||
// Code follows JSON-RPC conventions; see gitea-mcp internal/mcp/jsonrpc.go
|
||||
// for the codes the server uses (e.g. -32002 NotFound, -32003 Conflict).
|
||||
type Error struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return fmt.Sprintf("mcp error %d: %s", e.Code, e.Message) }
|
||||
|
||||
// CallTool issues `tools/call`. result is JSON-unmarshalled from the
|
||||
// server's content[0].text field; pass nil to discard.
|
||||
func (c *Client) CallTool(ctx context.Context, name string, args any, result any) error {
|
||||
body, err := json.Marshal(rpcRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Method: "tools/call",
|
||||
Params: map[string]any{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("mcp http %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var rpc rpcResponse
|
||||
if err := json.Unmarshal(raw, &rpc); err != nil {
|
||||
return fmt.Errorf("decode response: %w (body=%s)", err, string(raw))
|
||||
}
|
||||
if rpc.Error != nil {
|
||||
return &Error{Code: rpc.Error.Code, Message: rpc.Error.Message}
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MCP success result shape: { content: [{type:"text", text:"<json>"}] }
|
||||
var wrap struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(rpc.Result, &wrap); err != nil {
|
||||
return fmt.Errorf("decode wrap: %w (result=%s)", err, string(rpc.Result))
|
||||
}
|
||||
if len(wrap.Content) == 0 {
|
||||
return fmt.Errorf("empty content in tool response")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(wrap.Content[0].Text), result); err != nil {
|
||||
return fmt.Errorf("decode tool result text: %w (text=%s)", err, wrap.Content[0].Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
82
internal/mcpclient/client_test.go
Normal file
82
internal/mcpclient/client_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package mcpclient_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCallTool_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "Bearer tok", r.Header.Get("Authorization"))
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(b, &got)
|
||||
assert.Equal(t, "tools/call", got["method"])
|
||||
params := got["params"].(map[string]any)
|
||||
assert.Equal(t, "x_y", params["name"])
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"ok\":true,\"n\":7}"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := mcpclient.New(srv.URL, "tok")
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
err := c.CallTool(context.Background(), "x_y", map[string]any{"a": 1}, &out)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, out.OK)
|
||||
assert.Equal(t, 7, out.N)
|
||||
}
|
||||
|
||||
func TestCallTool_RPCError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32003,"message":"already exists"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := mcpclient.New(srv.URL, "")
|
||||
err := c.CallTool(context.Background(), "x", nil, nil)
|
||||
require.Error(t, err)
|
||||
var me *mcpclient.Error
|
||||
require.True(t, errors.As(err, &me))
|
||||
assert.Equal(t, -32003, me.Code)
|
||||
assert.Contains(t, me.Message, "already exists")
|
||||
}
|
||||
|
||||
func TestCallTool_HTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`unauthorized`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := mcpclient.New(srv.URL, "")
|
||||
err := c.CallTool(context.Background(), "x", nil, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "401")
|
||||
}
|
||||
|
||||
func TestCallTool_NilResult(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{}"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := mcpclient.New(srv.URL, "")
|
||||
require.NoError(t, c.CallTool(context.Background(), "x", nil, nil))
|
||||
}
|
||||
Reference in New Issue
Block a user