package mcpsrv
import (
"context"
"fmt"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// Writer is the write side of the orchestration layer the propose tool calls —
// the same service.Propose that the REST PUT calls, so the two write surfaces
// share one implementation of If-Match, provenance and auto-merge rather than
// drifting apart. *service.Service satisfies it.
//
// It is a distinct interface from Reader, and optional on the Backend, because
// the read tools need no write path and a read-only deployment (or a test)
// should be able to register the three read tools without a service that can
// mutate anything.
type Writer interface {
Propose(ctx context.Context, req service.ProposeRequest) (service.ProposeResult, error)
}
// proposeDoc is one whole-document upload. The write plane takes whole
// documents, not patches — that is how agents work and what makes the merge
// model plumbing — so an agent sends the full markdown it wants the document to
// have, frontmatter included.
type proposeDoc struct {
Path string `json:"path" jsonschema:"the document's path in the space, e.g. \"specs/0007-storage.md\""`
Content string `json:"content" jsonschema:"the whole document, frontmatter included, exactly as it should be stored"`
}
type proposeInput struct {
Space string `json:"space" jsonschema:"the space to propose against, written \"~owner/name\""`
// IfMatch is the base: the approved-head sha the agent read the document at,
// which is the rev field spec_read returns for an approved read. It pins the
// proposal's base and is what staleness is measured against.
IfMatch string `json:"if_match" jsonschema:"the approved-head revision you read at — the rev value from spec_read of the approved head. It becomes the proposal's base; a base the approved branch has moved off is rejected."`
Proposal int `json:"proposal,omitempty" jsonschema:"add these documents to an existing open proposal with this id, rather than opening a new one. Omit to open a new proposal."`
Title string `json:"title,omitempty" jsonschema:"a short title for a new proposal (required when opening one, ignored when adding)"`
Rationale string `json:"rationale,omitempty" jsonschema:"why the change is proposed, for the reviewer"`
Message string `json:"message,omitempty" jsonschema:"the commit message for this write; defaults to the title when opening"`
Documents []proposeDoc `json:"documents" jsonschema:"the whole documents to write, at least one"`
}
type proposeOutput struct {
// Proposal is the proposal id, and Url the stable link to hand a human. An
// agent that proposes without surfacing the url makes the work invisible.
Proposal int `json:"proposal"`
URL string `json:"url"`
// Merged reports whether auto-merge policy landed this immediately. When
// true the change is already on the approved head; when false it is open and
// waiting for a human, and the url is where they review it.
Merged bool `json:"merged"`
// State is the proposal's lifecycle state after this write: "open" or, when
// policy auto-merged, "merged".
State string `json:"state"`
// Branch is the proposal branch, "proposals/<id>". BaseRev is the base the
// proposal is measured against — the value to keep sending as if_match when
// adding to this proposal.
Branch string `json:"branch"`
BaseRev string `json:"base_rev"`
}
func proposeHandler(ctx context.Context, w Writer, in proposeInput) (proposeOutput, error) {
ref, err := parseSpace(in.Space)
if err != nil {
return proposeOutput{}, err
}
if len(in.Documents) == 0 {
return proposeOutput{}, fmt.Errorf("documents must not be empty; a proposal writes at least one whole document")
}
writes := make([]service.DocumentWrite, 0, len(in.Documents))
for _, d := range in.Documents {
writes = append(writes, service.DocumentWrite{Path: d.Path, Content: []byte(d.Content)})
}
// The principal is resolved by the resolver middleware on /mcp from the
// bearer token on this very request. service.Propose refuses a non-agent, so
// an anonymous or owner caller is rejected there rather than here — the ACL
// has one home, in service/.
principal := authn.PrincipalFromContext(ctx)
res, err := w.Propose(ctx, service.ProposeRequest{
Space: ref,
Principal: principal,
ProposalID: in.Proposal,
Title: in.Title,
Rationale: in.Rationale,
IfMatch: in.IfMatch,
Message: in.Message,
Writes: writes,
})
if err != nil {
return proposeOutput{}, err
}
return proposeOutput{
Proposal: res.Proposal.ID,
URL: res.URL,
Merged: res.Merged,
State: string(res.Proposal.State),
Branch: res.Proposal.Branch,
BaseRev: res.Proposal.BaseRev,
}, nil
}