Public: anyone can read this; the crew can change it.
The MCP person and pins tools refuse a limit outside 1–200 instead of clamping it, with the words the HTTP list routes use #8514
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8502-the-mcp-person-and-pins-tools-refuse-a-l → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +31
−11
src/http/mcp/tools/person.ts +5 −5
| @@ -2,17 +2,17 @@ import { pageLimit } from "../../../core/issues.ts"; | ||
| 2 | 2 | import { listReposByOwner } from "../../../core/repos.ts"; |
| 3 | 3 | import { getUserByName, hiddenFrom } from "../../../core/users.ts"; |
| 4 | 4 | import { paged, publicProfile } from "../../respond.ts"; |
| 5 | import { good, bad, type Tool } from "../shared.ts"; | |
| 5 | import { good, bad, badLimit, type Tool } from "../shared.ts"; | |
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | name: "person", |
| 9 | doc: "`{ username, limit?, cursor? }`: a person's public profile and the repositories you may see, as `GET /api/v1/users/:username` and its `/repos`; a private one you cannot read is never listed", | |
| 9 | doc: "`{ username, limit? (1 to 200), cursor? }`: a person's public profile and the repositories you may see, as `GET /api/v1/users/:username` and its `/repos`; a private one you cannot read is never listed", | |
| 10 | 10 | description: "A person's profile and repos you may see.", |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
| 14 | 14 | username: { type: "string" }, |
| 15 | limit: { type: "integer" }, | |
| 15 | limit: { type: "integer", minimum: 1, maximum: 200 }, | |
| 16 | 16 | cursor: { type: "string" }, |
| 17 | 17 | }, |
| 18 | 18 | required: ["username"], |
| @@ -22,8 +22,8 @@ export const tool: Tool = { | ||
| 22 | 22 | const { username, limit, cursor } = args; |
| 23 | 23 | if (typeof username !== "string" || username === "" || username.length > 100) |
| 24 | 24 | return bad("invalid", "`username` is 1 to 100 characters"); |
| 25 | if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit))) | |
| 26 | return bad("invalid", "`limit` is a number"); | |
| 25 | const limitRefused = badLimit(limit); | |
| 26 | if (limitRefused) return limitRefused; | |
| 27 | 27 | if (cursor !== undefined && typeof cursor !== "string") |
| 28 | 28 | return bad("invalid", "`cursor` is the one a previous answer gave"); |
| 29 | 29 | const person = getUserByName(ctx.app.db, username); |
src/http/mcp/tools/pins.ts +5 −5
| @@ -1,18 +1,18 @@ | ||
| 1 | 1 | import { pageLimit } from "../../../core/issues.ts"; |
| 2 | 2 | import { listPinned, pinRepo, unpinRepo } from "../../../actions/origins.ts"; |
| 3 | 3 | import { canRead, getReposByIds } from "../../../core/repos.ts"; |
| 4 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | |
| 4 | import { good, bad, badLimit, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "pins", |
| 8 | doc: "`{ action?, repo?, limit?, cursor? }`: `list` (the default) the caller's pinned repositories, paged by `owner/name` as `GET /api/v1/user/pins` is (`next_cursor`), leaving out a pin whose repository the caller can no longer read (the API's list still names it); `pin` or `unpin` `repo`, as `PUT`/`DELETE /api/v1/user/pins/:owner/:name` do. A repository the caller cannot see is not found", | |
| 8 | doc: "`{ action?, repo?, limit? (1 to 200), cursor? }`: `list` (the default) the caller's pinned repositories, paged by `owner/name` as `GET /api/v1/user/pins` is (`next_cursor`), leaving out a pin whose repository the caller can no longer read (the API's list still names it); `pin` or `unpin` `repo`, as `PUT`/`DELETE /api/v1/user/pins/:owner/:name` do. A repository the caller cannot see is not found", | |
| 9 | 9 | description: "Your pinned repos: list, pin or unpin `repo`.", |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
| 13 | 13 | action: { type: "string", enum: ["list", "pin", "unpin"] }, |
| 14 | 14 | repo: REPO_ARG, |
| 15 | limit: { type: "integer" }, | |
| 15 | limit: { type: "integer", minimum: 1, maximum: 200 }, | |
| 16 | 16 | cursor: { type: "string" }, |
| 17 | 17 | }, |
| 18 | 18 | }, |
| @@ -24,8 +24,8 @@ export const tool: Tool = { | ||
| 24 | 24 | return bad("invalid", "action is list, pin or unpin"); |
| 25 | 25 | if (action === "list") { |
| 26 | 26 | const { limit, cursor } = args; |
| 27 | if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit))) | |
| 28 | return bad("invalid", "`limit` is a number"); | |
| 27 | const limitRefused = badLimit(limit); | |
| 28 | if (limitRefused) return limitRefused; | |
| 29 | 29 | if (cursor !== undefined && typeof cursor !== "string") |
| 30 | 30 | return bad("invalid", "`cursor` is the one a previous answer gave"); |
| 31 | 31 | const size = pageLimit(limit as number | undefined); |
test/mcp-person.test.ts +9
| @@ -103,6 +103,15 @@ describe("MCP person", () => { | ||
| 103 | 103 | expect(next.value.repos.items[0].name).not.toBe(first.value.repos.items[0].name); |
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | test("a limit outside 1 to 200 is refused, 1 and 200 pass", async () => { | |
| 107 | for (const bad of [0, -5, 9999, 201, 1.5, "x"]) | |
| 108 | expect((await tool(bobRo, { username: "guild", limit: bad })).error).toContain( | |
| 109 | "invalid_limit", | |
| 110 | ); | |
| 111 | expect((await tool(bobRo, { username: "guild", limit: 1 })).value.repos.items).toHaveLength(1); | |
| 112 | expect((await tool(bobRo, { username: "guild", limit: 200 })).value).toBeDefined(); | |
| 113 | }); | |
| 114 | ||
| 106 | 115 | test("a suspended person is not found to a non-admin, and still found by an admin", async () => { |
| 107 | 116 | expect((await tool(carolRo, { username: "dave" })).error).toContain("no such person"); |
| 108 | 117 | expect((await tool(aliceRo, { username: "dave" })).value.username).toBe("dave"); |
test/mcp-pins.test.ts +4
| @@ -121,6 +121,10 @@ describe("MCP pins", () => { | ||
| 121 | 121 | expect(second.pins.map((p: any) => p.repo)).toContain("alice/pc"); |
| 122 | 122 | expect(second.next_cursor).toBeNull(); |
| 123 | 123 | expect((await tool(bobRw, { limit: "x" })).error).toContain("invalid"); |
| 124 | for (const bad of [0, -5, 9999, 201, 1.5, "x"]) | |
| 125 | expect((await tool(bobRw, { limit: bad })).error).toContain("invalid_limit"); | |
| 126 | expect((await tool(bobRw, { limit: 1 })).value.pins).toHaveLength(1); | |
| 127 | expect((await tool(bobRw, { limit: 200 })).value.pins.length).toBeGreaterThan(0); | |
| 124 | 128 | for (const n of ["pa", "pb", "pc"]) await tool(bobRw, { action: "unpin", repo: `alice/${n}` }); |
| 125 | 129 | }); |
| 126 | 130 |
new above
offline
Medium: badLimit answers code
invalidwith backticks around limit. The HTTP list routes and the MCP issues and page helpers answerinvalid_limitwithlimit is a whole number from 1 to 200. Change badLimit in shared.ts to that, so a client sees one refusal across doors. Also addminimum: 1, maximum: 200to thelimitschema (line 15), as issues.ts does.