| 1 |
|
// Package mcpsrv is spec.sr.ht's Model Context Protocol surface: the tools an |
| 2 |
|
// agent calls to find and read approved documents. |
| 3 |
|
// |
| 4 |
|
// It is warren's mcpsrv/ absorbed, and it keeps warren's two structural |
| 5 |
|
// choices — a narrow backend interface the tools are written against, and the |
| 6 |
|
// SDK's generic AddTool deriving every schema from a Go struct — while |
| 7 |
|
// discarding everything that assumed a local vault: the vault-wide id space, |
| 8 |
|
// the parent/child tools, and the hybrid keyword+semantic mode argument (vector |
| 9 |
|
// search is Phase 5 here). |
| 10 |
|
// |
| 11 |
|
// # A surface, not a wrapper |
| 12 |
|
// |
| 13 |
|
// The design's rule is that MCP is a first-class surface and that its tools |
| 14 |
|
// call the same resolver layer as REST, GraphQL and the web UI rather than a |
| 15 |
|
// parallel implementation. That is what this package is: every lookup goes |
| 16 |
|
// through service/, every addressing decision through doc.Archive, and every |
| 17 |
|
// query through search.Index. Nothing here re-derives which document an id |
| 18 |
|
// names or which spaces a project covers, because a second implementation of |
| 19 |
|
// those rules is how two surfaces start answering the same question |
| 20 |
|
// differently — silently, and months later. |
| 21 |
|
// |
| 22 |
|
// # The read contract is the reason this exists |
| 23 |
|
// |
| 24 |
|
// Bots need "the approved text of SPEC-0007", not "whatever a branch points at |
| 25 |
|
// while another agent rewrites it". So: |
| 26 |
|
// |
| 27 |
|
// - Omitting rev reads the space's approved head. That is the default, |
| 28 |
|
// and it is service.ApprovedRev — the same default every other surface has. |
| 29 |
|
// - Passing rev pins the read to one immutable revision, and rev must be an |
| 30 |
|
// object name: 7-64 lowercase hex, the same grammar the design pins for |
| 31 |
|
// X-Agent-Base. Ref names are refused outright, which is what makes |
| 32 |
|
// "read a proposal branch" impossible to reach by accident or by |
| 33 |
|
// mistyping — an agent can only reach unapproved content by naming a |
| 34 |
|
// commit sha it had to obtain deliberately, since neither spec_search nor |
| 35 |
|
// spec_list ever reports one. |
| 36 |
|
// |
| 37 |
|
// Serving drafts by default would poison every downstream agent context with |
| 38 |
|
// unreviewed text, which is the exact failure the service exists to prevent. |
| 39 |
|
// |
| 40 |
|
// # The write plane |
| 41 |
|
// |
| 42 |
|
// Two tools write, and both are registered only when a write backend is wired: |
| 43 |
|
// spec_propose opens or extends a proposal, and spec_comment reads a proposal's |
| 44 |
|
// review threads and replies to them. Neither can approve, merge, open a review |
| 45 |
|
// thread or resolve one — those are the owner's, in service/, and the |
| 46 |
|
// interfaces here do not name them. |
| 47 |
|
// |
| 48 |
|
// # Layering |
| 49 |
|
// |
| 50 |
|
// Reader and Searcher are declared here rather than imported as concrete types |
| 51 |
|
// so the tools can be tested without a repository, a Postgres instance or a |
| 52 |
|
// bleve index. *service.Service satisfies Reader and *search.Index satisfies |
| 53 |
|
// Searcher, structurally, with no adapter. |
| 54 |
|
package mcpsrv |
| 55 |
|
|
| 56 |
|
import ( |
| 57 |
|
"context" |
| 58 |
|
"errors" |
| 59 |
|
"fmt" |
| 60 |
|
"net" |
| 61 |
|
"net/http" |
| 62 |
|
"strings" |
| 63 |
|
|
| 64 |
|
"github.com/modelcontextprotocol/go-sdk/mcp" |
| 65 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/instconf" |
| 66 |
|
|
| 67 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 68 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/search" |
| 69 |
|
) |
| 70 |
|
|
| 71 |
|
// ServerName is the implementation name reported in the MCP handshake. It is |
| 72 |
|
// the service's config-section name, so a client listing several SourceHut MCP |
| 73 |
|
// endpoints sees which one it is talking to. |
| 74 |
|
const ServerName = "spec.sr.ht" |
| 75 |
|
|
| 76 |
|
// maxSearchLimit caps how many hits one call may ask for. |
| 77 |
|
const maxSearchLimit = 100 |
| 78 |
|
|
| 79 |
|
// Backend is everything the tools read through. Both halves are required: a |
| 80 |
|
// nil one is a wiring mistake, and New reports it at startup rather than |
| 81 |
|
// letting the first tool call panic inside a request. |
| 82 |
|
type Backend struct { |
| 83 |
|
// Docs is the orchestration layer — in production *service.Service. |
| 84 |
|
Docs Reader |
| 85 |
|
// Index is the one global bleve index — in production *search.Index. |
| 86 |
|
Index Searcher |
| 87 |
|
// Write is the write side — in production *service.Service. It is optional: |
| 88 |
|
// when nil the server registers only the read tools, so a read-only |
| 89 |
|
// deployment or a test needs no mutable backend. When set, spec_propose is |
| 90 |
|
// registered and every write goes through the same service.Propose the REST |
| 91 |
|
// PUT calls. |
| 92 |
|
Write Writer |
| 93 |
|
} |
| 94 |
|
|
| 95 |
|
// New builds the MCP server with the Phase 2 read tools registered. version is |
| 96 |
|
// reported as the implementation version in the handshake. |
| 97 |
|
// |
| 98 |
|
// The returned server is not connected to a transport; Handler wires it to |
| 99 |
|
// streamable HTTP, and a caller that wants stdio can call Run itself. |
| 100 |
47 |
func New(b Backend, version string) (*mcp.Server, error) { |
| 101 |
47 |
if b.Docs == nil { |
| 102 |
2 |
return nil, errors.New("mcpsrv: backend has no document reader") |
| 103 |
2 |
} |
| 104 |
45 |
if b.Index == nil { |
| 105 |
1 |
return nil, errors.New("mcpsrv: backend has no search index") |
| 106 |
1 |
} |
| 107 |
|
|
| 108 |
44 |
srv := mcp.NewServer(&mcp.Implementation{Name: ServerName, Version: version}, nil) |
| 109 |
44 |
readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true} |
| 110 |
44 |
|
| 111 |
44 |
mcp.AddTool(srv, &mcp.Tool{ |
| 112 |
44 |
Name: "spec_search", |
| 113 |
44 |
Annotations: readOnly, |
| 114 |
44 |
Description: "Search every approved document on this instance, ranked. Each hit carries " + |
| 115 |
44 |
"the space, the document id, its path, the revision it was indexed at, its title " + |
| 116 |
44 |
"and a plain-text snippet — enough to fetch it with spec_read without a second " + |
| 117 |
44 |
"lookup.\n\n" + |
| 118 |
44 |
"Pass `spaces` to restrict the search to a set of spaces: that set is what a " + |
| 119 |
44 |
"project is on this service — a saved filter over one global index, not a " + |
| 120 |
44 |
"container. Omitting it searches everything, which is the meta-project.\n\n" + |
| 121 |
44 |
"Results come from the approved revision of each space. Proposal branches are " + |
| 122 |
44 |
"not indexed and never appear here.", |
| 123 |
44 |
}, func(ctx context.Context, _ *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) { |
| 124 |
10 |
out, err := searchHandler(ctx, b, in) |
| 125 |
10 |
return nil, out, err |
| 126 |
10 |
}) |
| 127 |
|
|
| 128 |
44 |
mcp.AddTool(srv, &mcp.Tool{ |
| 129 |
44 |
Name: "spec_read", |
| 130 |
44 |
Annotations: readOnly, |
| 131 |
44 |
Description: "Read one document's markdown, frontmatter included, exactly as it is stored.\n\n" + |
| 132 |
44 |
"By default this returns the space's APPROVED text — the reviewed, canonical " + |
| 133 |
44 |
"revision — and reports the revision it resolved to in `rev`. Pass that value " + |
| 134 |
44 |
"back as the `rev` argument later to re-read the identical bytes; a revision, " + |
| 135 |
44 |
"once named, is immutable.\n\n" + |
| 136 |
44 |
"Address the document by its frontmatter id (\"SPEC-0007\") when it has a " + |
| 137 |
44 |
"well-formed one that no other document in the space claims, and otherwise by " + |
| 138 |
44 |
"its path, with or without the \".md\" extension. A document whose id is " + |
| 139 |
44 |
"duplicated within its space resolves to neither document and is reported as " + |
| 140 |
44 |
"ambiguous rather than guessed at.", |
| 141 |
44 |
}, func(ctx context.Context, _ *mcp.CallToolRequest, in readInput) (*mcp.CallToolResult, readOutput, error) { |
| 142 |
24 |
out, err := readHandler(ctx, b, in) |
| 143 |
24 |
return nil, out, err |
| 144 |
24 |
}) |
| 145 |
|
|
| 146 |
44 |
mcp.AddTool(srv, &mcp.Tool{ |
| 147 |
44 |
Name: "spec_list", |
| 148 |
44 |
Annotations: readOnly, |
| 149 |
44 |
Description: "List spaces, or list the documents in one space.\n\n" + |
| 150 |
44 |
"Omit `space` to get every space on the instance — those names are what " + |
| 151 |
44 |
"spec_search's `spaces` filter takes. Pass `space` to get that space's " + |
| 152 |
44 |
"documents at its approved head, each with the id spec_read addresses it by, " + |
| 153 |
44 |
"its path, title, section and authored status. Pass `rev` as well to list a " + |
| 154 |
44 |
"pinned revision instead.", |
| 155 |
44 |
}, func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) { |
| 156 |
5 |
out, err := listHandler(ctx, b, in) |
| 157 |
5 |
return nil, out, err |
| 158 |
5 |
}) |
| 159 |
|
|
| 160 |
|
// The write tools, registered only when a write backend is wired. Neither is |
| 161 |
|
// read-only or idempotent — proposing twice opens two proposals, replying |
| 162 |
|
// twice says it twice — so neither carries a hint, which is how a client |
| 163 |
|
// tells a tool it can retry freely from one it cannot. |
| 164 |
44 |
if b.Write != nil { |
| 165 |
4 |
mcp.AddTool(srv, &mcp.Tool{ |
| 166 |
4 |
Name: "spec_propose", |
| 167 |
4 |
Description: "Propose a change to a space: upload whole documents and get back a proposal " + |
| 168 |
4 |
"and a URL to hand a human for review.\n\n" + |
| 169 |
4 |
"Pass `if_match` as the `rev` you read the approved head at (from spec_read) — it becomes " + |
| 170 |
4 |
"the proposal's base, and a base the approved branch has moved off is rejected so you " + |
| 171 |
4 |
"refetch and re-propose. Each document in `documents` is the WHOLE markdown, frontmatter " + |
| 172 |
4 |
"included; there are no patches.\n\n" + |
| 173 |
4 |
"Omit `proposal` to open a new one (give it a `title`); pass an existing proposal id to add " + |
| 174 |
4 |
"more documents to it, sending the same `if_match` you opened it with.\n\n" + |
| 175 |
4 |
"The response always carries the proposal `url`. Surface it: the human reviews there, and a " + |
| 176 |
4 |
"proposal whose link you never mention is invisible. When `merged` is true the space's " + |
| 177 |
4 |
"auto_merge policy landed the change immediately; otherwise it is open and waiting.", |
| 178 |
4 |
}, func(ctx context.Context, _ *mcp.CallToolRequest, in proposeInput) (*mcp.CallToolResult, proposeOutput, error) { |
| 179 |
0 |
out, err := proposeHandler(ctx, b.Write, in) |
| 180 |
0 |
return nil, out, err |
| 181 |
0 |
}) |
| 182 |
|
|
| 183 |
4 |
mcp.AddTool(srv, &mcp.Tool{ |
| 184 |
4 |
Name: "spec_comment", |
| 185 |
4 |
Description: "Read the review threads on a proposal, and reply to one.\n\n" + |
| 186 |
4 |
"Pass only `proposal` to list its threads: each carries the owner's critique, its " + |
| 187 |
4 |
"replies, the document and heading path it is anchored to, and whether it is still " + |
| 188 |
4 |
"open.\n\n" + |
| 189 |
4 |
"Read `state` before acting on a thread. \"anchored\" means the block you were " + |
| 190 |
4 |
"criticised for is still there verbatim; \"edited\" means the block is still in that " + |
| 191 |
4 |
"position but its text changed after the comment was written, so the critique may " + |
| 192 |
4 |
"already be addressed; \"outdated\" means the anchor lost its block entirely and the " + |
| 193 |
4 |
"comment describes text that is no longer in the proposal. Fixing what an outdated " + |
| 194 |
4 |
"comment asks for edits something else.\n\n" + |
| 195 |
4 |
"Pass `thread` and `body` to reply to that thread. Replying does not close it — only " + |
| 196 |
4 |
"the owner resolves a thread, and an open thread holds back auto-merge. So answer the " + |
| 197 |
4 |
"critique and push the revision with spec_propose; do not expect the reply itself to " + |
| 198 |
4 |
"unblock the proposal.", |
| 199 |
4 |
}, func(ctx context.Context, _ *mcp.CallToolRequest, in commentInput) (*mcp.CallToolResult, commentOutput, error) { |
| 200 |
1 |
out, err := commentHandler(ctx, b.Write, in) |
| 201 |
1 |
return nil, out, err |
| 202 |
1 |
}) |
| 203 |
|
} |
| 204 |
|
|
| 205 |
44 |
return srv, nil |
| 206 |
|
} |
| 207 |
|
|
| 208 |
|
// Handler mounts the server on streamable HTTP. cmd/specsrht hangs it off the |
| 209 |
|
// chi router at /mcp, which is what keeps MCP to one listener and one nginx |
| 210 |
|
// block rather than a second port. |
| 211 |
|
// |
| 212 |
|
// The SDK's DNS-rebinding guard is disabled deliberately, and the reason is |
| 213 |
|
// worth stating because disabling a security default usually is not. |
| 214 |
|
// |
| 215 |
|
// The guard rejects any request arriving on a loopback address that carries a |
| 216 |
|
// non-loopback Host header. That is precisely our deployment: the daemon binds |
| 217 |
|
// 127.0.0.1:5091 and nginx forwards with `proxy_set_header Host $host`, so |
| 218 |
|
// every genuine request would 403 — and it would 403 only in production, |
| 219 |
|
// because a local client sends a loopback Host and passes. |
| 220 |
|
// |
| 221 |
|
// It is not that the guard has nothing to catch. A browser running on the |
| 222 |
|
// daemon's own host could reach 127.0.0.1:5091 directly with an attacker's |
| 223 |
|
// Host header, which is the attack the guard is for. The problem is that the |
| 224 |
|
// guard cannot tell that request from nginx's: both arrive from loopback |
| 225 |
|
// carrying a Host that is not loopback, and the SDK exposes no allowlist to |
| 226 |
|
// separate them. |
| 227 |
|
// |
| 228 |
|
// So the guard is disabled and REPLACED, in the same constructor, by allowHosts |
| 229 |
|
// below — a stricter check than the one removed. The SDK asks only "is Host |
| 230 |
|
// loopback?"; we require Host to equal this instance's configured origin. A |
| 231 |
|
// rebinding attack carries the attacker's name in Host and fails that; nginx |
| 232 |
|
// forwards our real hostname and passes. Disabling the SDK guard without this |
| 233 |
|
// replacement would be a genuine regression, not a formality. |
| 234 |
|
// |
| 235 |
|
// origin is therefore required, and an origin with no host in it is refused |
| 236 |
|
// here rather than warned about and then served unguarded. This used to warn: |
| 237 |
|
// it logged that Host validation was disabled and returned the bare handler, |
| 238 |
|
// on the reasoning that refusing to start over a config typo is worse than |
| 239 |
|
// running without the guard. That trade is the wrong way round. The guard is |
| 240 |
|
// the *only* thing protecting /mcp once the SDK's own is disabled, so the warn |
| 241 |
|
// path turned one unparseable config value into a silently open endpoint — |
| 242 |
|
// discoverable, in principle, from a log line nobody reads, and indistinguishable |
| 243 |
|
// in every functional test from a correctly guarded one. A daemon cannot reach |
| 244 |
|
// this call with such an origin anyway: service.Config.Validate already refuses |
| 245 |
|
// to start unless [spec.sr.ht] origin parses and carries a host. A caller that |
| 246 |
|
// got here without one is not an operator to be warned, it is a bug. |
| 247 |
23 |
func Handler(b Backend, version, origin string) (http.Handler, error) { |
| 248 |
23 |
srv, err := New(b, version) |
| 249 |
23 |
if err != nil { |
| 250 |
1 |
return nil, err |
| 251 |
1 |
} |
| 252 |
|
// instconf.OriginHost and not a local parse: this is the reading of an |
| 253 |
|
// origin, and it is the same one authn's mailbox derivation and service's |
| 254 |
|
// config validation make. An origin nobody can extract a host from answers |
| 255 |
|
// "" here — never a guess such as "localhost", which would silently make |
| 256 |
|
// every malformed origin agree with a local client on the one code path |
| 257 |
|
// where that decides an allowlist. |
| 258 |
22 |
want := instconf.OriginHost(origin) |
| 259 |
22 |
if want == "" { |
| 260 |
5 |
return nil, fmt.Errorf("mcpsrv: origin %q has no host to guard /mcp with", origin) |
| 261 |
5 |
} |
| 262 |
17 |
h := mcp.NewStreamableHTTPHandler( |
| 263 |
17 |
func(*http.Request) *mcp.Server { return srv }, |
| 264 |
|
&mcp.StreamableHTTPOptions{DisableLocalhostProtection: true}, |
| 265 |
|
) |
| 266 |
|
// The cache directives wrap the Host allowlist rather than the other way |
| 267 |
|
// round, so that the 403 carries them too: a refusal by hostname is as |
| 268 |
|
// unstorable as an answer, and it is written before the SDK is reached at all. |
| 269 |
17 |
return privateCache(allowHosts(h, want)), nil |
| 270 |
|
} |
| 271 |
|
|
| 272 |
|
// allowHosts is this endpoint's DNS-rebinding protection, in the form the |
| 273 |
|
// deployment actually needs: Host must be the service's own origin hostname, or |
| 274 |
|
// a loopback name for local development. |
| 275 |
|
// |
| 276 |
|
// want is the hostname already extracted from the origin by Handler, which is |
| 277 |
|
// also where an origin that yields none is refused. It is a resolved host and |
| 278 |
|
// never an origin, so there is no path through this function that leaves the |
| 279 |
|
// guard off. |
| 280 |
17 |
func allowHosts(next http.Handler, want string) http.Handler { |
| 281 |
18 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 282 |
18 |
if !hostAllowed(r.Host, want) { |
| 283 |
4 |
http.Error(w, "Forbidden: unexpected Host header", http.StatusForbidden) |
| 284 |
4 |
return |
| 285 |
4 |
} |
| 286 |
14 |
next.ServeHTTP(w, r) |
| 287 |
|
}) |
| 288 |
|
} |
| 289 |
|
|
| 290 |
|
// Gate refuses a caller with no read authority before any MCP method — not just |
| 291 |
|
// tools/call, but initialize and tools/list too — reaches the server. The ACL |
| 292 |
|
// is authn.Principal.CanRead — the owner and its agents may read and nobody else |
| 293 |
|
// may — the same predicate graph's /query and the web UI apply, so the three |
| 294 |
|
// read surfaces cannot drift into three policies, which is how a corpus leaks. |
| 295 |
|
// |
| 296 |
|
// It reads the principal the resolver middleware set, so it must be mounted |
| 297 |
|
// INSIDE that middleware: |
| 298 |
|
// |
| 299 |
|
// mcp = resolver.Middleware()(mcpsrv.Gate(handler)) |
| 300 |
|
// |
| 301 |
|
// Without it, every read tool served approved content to anyone who cleared the |
| 302 |
|
// Host allowlist — spec_propose was already fail-closed in service.Propose, but |
| 303 |
|
// spec_search/spec_read/spec_list checked nothing. The refusal is a 401 with a |
| 304 |
|
// line of plain text and never a login redirect: every caller here is a machine. |
| 305 |
|
// |
| 306 |
|
// It checks identity and not grants, deliberately. This one endpoint carries |
| 307 |
|
// both the read tools and the write ones, and the tool being called is in the |
| 308 |
|
// JSON-RPC body, not the request — so a surface-wide spec:read would refuse a |
| 309 |
|
// tokens.sr.ht token minted for spec:propose alone at `initialize`, before it |
| 310 |
|
// ever named a tool. The grant is therefore checked per tool, by requireRead and |
| 311 |
|
// by service.Propose, each of which knows what is being attempted. |
| 312 |
|
// |
| 313 |
|
// The refusal carries the cache directives itself, which privateCache would |
| 314 |
|
// otherwise have written for it. It has to: Gate runs inside the resolver |
| 315 |
|
// middleware and Handler runs inside Gate, so a 401 written here never reaches |
| 316 |
|
// the wrapper Handler installs. A shared cache free to keep this 401 and replay |
| 317 |
|
// it to the next caller would refuse a credential this service never saw. |
| 318 |
10 |
func Gate(next http.Handler) http.Handler { |
| 319 |
10 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 320 |
10 |
if !authn.PrincipalFromContext(r.Context()).CanRead() { |
| 321 |
4 |
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 322 |
4 |
w.Header().Set("WWW-Authenticate", authn.Challenge()) |
| 323 |
4 |
w.Header().Set("Cache-Control", cacheControl) |
| 324 |
4 |
w.Header().Set("Vary", cacheVary) |
| 325 |
4 |
http.Error(w, "authentication required", http.StatusUnauthorized) |
| 326 |
4 |
return |
| 327 |
4 |
} |
| 328 |
6 |
next.ServeHTTP(w, r) |
| 329 |
|
}) |
| 330 |
|
} |
| 331 |
|
|
| 332 |
|
// requireRead is the grant half of the read ACL, for the tools that serve |
| 333 |
|
// content: Gate has already established that the caller may read at all, and |
| 334 |
|
// this asks whether the credential they used was minted for it. |
| 335 |
|
// |
| 336 |
|
// It is a no-op for the owner's cookie and for spec's own agent token, neither |
| 337 |
|
// of which carries grants — so every client that works today keeps working — and |
| 338 |
|
// refuses a tokens.sr.ht working token that lacks spec:read. |
| 339 |
63 |
func requireRead(ctx context.Context) error { |
| 340 |
63 |
return authn.PrincipalFromContext(ctx).Authorize(authn.ActionRead) |
| 341 |
63 |
} |
| 342 |
|
|
| 343 |
|
// hostAllowed compares a request's Host against the expected hostname, ignoring |
| 344 |
|
// any port and IPv6 brackets. Loopback names stay allowed so `make run-dev` and |
| 345 |
|
// a local MCP client keep working. |
| 346 |
18 |
func hostAllowed(reqHost, want string) bool { |
| 347 |
18 |
h := reqHost |
| 348 |
18 |
if stripped, _, err := net.SplitHostPort(h); err == nil { |
| 349 |
8 |
h = stripped |
| 350 |
8 |
} |
| 351 |
18 |
h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]") |
| 352 |
18 |
switch { |
| 353 |
7 |
case strings.EqualFold(h, want): |
| 354 |
7 |
return true |
| 355 |
7 |
case h == "localhost", h == "127.0.0.1", h == "::1": |
| 356 |
7 |
return true |
| 357 |
4 |
default: |
| 358 |
4 |
return false |
| 359 |
|
} |
| 360 |
|
} |
| 361 |
|
|
| 362 |
|
// clampLimit applies the hit-count policy: unset defers to search's own |
| 363 |
|
// default rather than restating it, and anything larger than maxSearchLimit is |
| 364 |
|
// clamped. A tool result is a context window, so an agent asking for a thousand |
| 365 |
|
// hits is asking for something it cannot use. |
| 366 |
8 |
func clampLimit(n int) int { |
| 367 |
8 |
switch { |
| 368 |
6 |
case n <= 0: |
| 369 |
6 |
return search.DefaultLimit |
| 370 |
1 |
case n > maxSearchLimit: |
| 371 |
1 |
return maxSearchLimit |
| 372 |
1 |
default: |
| 373 |
1 |
return n |
| 374 |
|
} |
| 375 |
|
} |