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 write tools call. // *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. // // It is a union of one narrow interface per write tool rather than a flat // method list, and each handler takes only its own half. That is what stops // spec_comment from reaching Propose and — the reason it matters — what stops // it from reaching a resolve method however service/ grows; see [Commenter]. type Writer interface { Proposer Commenter } // Proposer is what spec_propose calls — the same service.Propose the REST PUT // calls, so the two write surfaces share one implementation of If-Match, // provenance and auto-merge rather than drifting apart. type Proposer 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/". 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 Proposer, 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 }