MCP Server
Pawtograder exposes a Model Context Protocol (MCP) server as a Supabase Edge Function. The request handler and tool registry live insupabase/functions/mcp-server/index.ts, authentication in supabase/functions/_shared/MCPAuth.ts, and token issuance and revocation in supabase/functions/mcp-tokens/index.ts.
This page covers the endpoint, the wire protocol, the two authorization layers, the 16 tools, and how to add another. For instructor-facing setup, such as connecting Claude Desktop and creating a token, see AI assistance for helping students.
Endpoint
The function is declared at
supabase/config.toml:775-779 and answers while npx supabase functions serve is running.
Protocol
initialize advertises protocol version 2024-11-05, and tools is the only capability (index.ts:1862-1876):
2024-11-05 is the older MCP revision, even though the handler implements the streamable HTTP transport. It also keeps the legacy HTTP+SSE endpoint event for backwards compatibility.
JSON-RPC batch arrays are supported (
index.ts:2156-2197). Each request in the array that carries an id is handled in order and the responses come back as an array. A batch with no id-bearing request returns HTTP 202 and no body, as does a single request sent without an id.
Tool results are stringified JSON in a text block
Everytools/call result wraps the handler’s return value in one text block (index.ts:1891-1908):
JSON.stringify(result, null, 2). There is no structured content, so a client has to parse content[0].text itself. A handler that finds nothing returns null, which arrives as the literal four-character string null inside that text block rather than as an error.
Errors thrown inside a tool come back as a JSON-RPC error with code -32000 and the thrown message, still under HTTP 200 (index.ts:1932-1944).
Transport
The method andAccept checks run before authentication, so several branches answer without a token:
The two verbs use different error envelopes on the same URL.
POST errors are JSON-RPC shaped, with code -32000 for a 401 and -32603 otherwise (index.ts:2233-2244). GET errors are a plain {"error": "<message>"} object (index.ts:2121-2124). Advertised CORS headers include mcp-session-id and last-event-id.
POST replies with application/json unless the client sends Accept: text/event-stream without also accepting JSON, in which case the responses are framed as SSE events. On GET, the server emits the legacy endpoint event only when EDGE_FUNCTIONS_URL is set; it deliberately refuses to derive that URL from x-forwarded-* or Host, because those are client-controllable (index.ts:2018-2044).
API tokens
The server accepts tokens issued by Pawtograder’s own API token system in theAuthorization: Bearer <token> header.
Create a token from the user menu on a course page: click your avatar, then API Tokens. It is not under Settings, and the whole menu item is hidden while you preview the course as a student (
app/course/[course_id]/UserMenu.tsx:787). The same dialog issues CLI tokens; see the CLI reference.
Scopes
VALID_SCOPES holds four scopes: mcp:read, mcp:write, cli:read, and cli:write (MCPAuth.ts:36-37). The dialog maps the three token types onto them (components/settings/MCPTokensMenu.tsx:17-21):
A CLI-only token is rejected on the first
tools/call with 403 Missing required scope: mcp:read, because the scope check runs per tool inside executeTool (index.ts:1738, MCPAuth.ts:375-378).
Authorization
Authorization happens in two layers, and they answer different questions.Layer 1: is the caller staff anywhere on this deployment?
authenticateMCPRequest runs before any tool (MCPAuth.ts:300-363), in this order:
- Reject a missing
Authorizationheader, then a header that is not exactly two space-separated parts with a case-insensitivebearerfirst part. - Verify the token signature and required claims with
verifyApiToken. - Look up
jtiinrevoked_token_ids. - Query
user_roleswith the service role for any non-disabled row where the user holdsinstructororgrader. Zero rows gives403 User must be an instructor or grader in at least one class(MCPAuth.ts:351).
Layer 2: Postgres row-level security
Tool handlers never receive a service-role client.createAuthenticatedSupabaseClient builds a client from the anon key plus a Supabase JWT created for the calling user: ES256, signed with the JWK in JWT_SECRET, claims sub: <userId>, role: "authenticated", aud: "authenticated", and a 60-second expiry, cached per user for about 55 seconds (MCPAuth.ts:237-294). Every query a tool makes therefore runs as that user under the same RLS policies as the web app, and most handlers also filter on .eq("class_id", classId) explicitly.
The service role appears only in the authentication layer: the role probe above, the revocation lookup, and the last_used_at update. Nothing inside a tool handler escalates.
Three privacy properties hold on top of RLS:
- No tool handler queries the
userstable. is_private_profileis not part of any response type (supabase/functions/mcp-server/types.ts:6).- Private profile ids are translated to public profiles before a response is built, so what comes back is the public display name (
getPublicProfiles,index.ts:465-469). The response profile type carries onlyid,name,avatar_url, andclass_id.
listGraderFiles (index.ts:1070-1076) and getGraderFilesFiltered (:1113-1124) query autograder by id only. They accept classId and do not use it, unlike every other assignment handler, so cross-class isolation for the two grader-file tools comes entirely from RLS. The one policy on that table, instructors rw, is scoped through the assignment’s class with authorizeforclassinstructor (supabase/migrations/20250330003141_remote_schema.sql:2622-2624), so isolation does hold. It also means a grader cannot read autograder at all: list_grader_files and get_grader_files return an empty file list for a grader rather than an error, because the handlers treat a failed lookup as no files.Tools
TOOLS (index.ts:90-346) holds 16 tools. Every one declares requiredScope: "mcp:read"; there is no write tool in the registry. tools/list maps directly over this object, so it is the authoritative inventory.
Every tool takes
class_id as an explicit parameter. There is no session, no “current class”, and no default: a call that omits class_id fails schema validation. An assistant has to learn the class id from the prompt it was given.Context
Submissions
student_profile_id is a string, and it is the student’s public profile id (index.ts:287). Despite the plural name, get_submissions_for_student is a fetch by student and assignment, not a search.
Both of get_test_output’s selectors are optional, so a call carrying only submission_id and class_id passes schema validation. Supply test_id or test_name.
Repositories
These read from GitHub, capped at 100 files with a fetch concurrency of 10 (
index.ts:357-358).
Search
Tools the in-app prompts name but the server does not implement
The AI buttons in the staff UI generate a prompt that tells the assistant which tools to call. Three of those generators name tools that are not in the registry, so the assistant reports an unknown tool:executeTool throws Unknown tool: <name> for a name that is not a key of TOOLS (index.ts:1734), which is what a client sees for all three. If you are chasing an unknown-tool report, check the prompt generator before the server.
The four repository tools appear in no prompt generator at all, and only AIHelpButton.tsx mentions get_help_request or get_discussion_thread.
Assignment and handout context
get_assignment selects exactly id, title, slug, description, handout_url, due_date, release_date, total_points, has_autograder, and class_id (index.ts:584-593). It returns no rubric: the word rubric does not appear anywhere in mcp-server/index.ts or mcp-server/types.ts. Rubrics are not reachable over MCP at all, so an assistant that needs one has to be given it, or you export it with rubrics export.
handout_url lets an assistant fetch the written specification without any extra credentials. In get_help_request, however, the assignment block stays null unless the help request has a referenced_submission_id, because the handler derives the assignment from the referenced submission (index.ts:1385-1405). A help request with no submission attached carries no assignment context and no handout URL.
Extension points
Adding a tool takes two edits, not one:- Add the descriptor (
name,description,inputSchema,requiredScope) toTOOLSatindex.ts:90. This alone only makes the tool appear intools/list. - Add a
caseto theswitchinexecuteTool(index.ts:1741-1850). Without it the call reachesdefaultand throwsTool not implemented: <name>, which reaches the client as JSON-RPC-32000.
context.supabase, the per-user client from layer 2, so RLS applies. Take class_id as a required parameter and filter on it, matching the rest of the registry.
Expanding an existing tool. Prefer composing existing queries over loosening RLS. If a new field needs privileged access, add a view with its own RLS policy rather than a service-role query inside the handler.
Write tools. mcp:write already exists in VALID_SCOPES and is already in every MCP token, so a mutating tool sets requiredScope: "mcp:write" and needs no new scope. Every MCP token already issued carries it.
Changing a tool signature. Regenerate shared types with npm run client-local if the schema changed, and update the staff-facing tool list in staff/ai-assistance.mdx.
Troubleshooting
401 Unauthorized
One of four messages, all from authenticateMCPRequest (MCPAuth.ts:300-327):
403 Forbidden
Two distinct causes:
User must be an instructor or grader in at least one class. The token is valid, but the user holds no non-disabledinstructororgraderrole in any class. This is not about the class you targeted.Missing required scope: mcp:read. The token was issued as CLI-only.
500 naming an environment variable
A message such as MCP_JWT_SECRET must be set and at least 32 characters is a deployment fault, not a problem with your token, and no token will work until it is fixed. MCPConfigError carries status 500 for exactly this reason (MCPAuth.ts:413-418), and verifyApiToken resolves the signing key outside its try block on purpose so a missing key is not reported as the caller’s token being invalid (MCPAuth.ts:165-168).
The same missing
MCP_JWT_SECRET also breaks token creation, which fails with HTTP 500 {"error":"MCP_JWT_SECRET must be set and at least 32 characters"}. Listing tokens keeps working, because createApiToken is called only on the create path (mcp-tokens/index.ts:203), so the API Tokens dialog looks healthy until you click Create Token. That call also precedes the api_tokens insert at :207, so a failed create leaves no unusable token row behind.
503 Service Unavailable
Could not verify token revocation status: <message>. The revoked_token_ids lookup failed, so the request is rejected without deciding whether the token is good (MCPAuth.ts:227). It is deliberately not a 401, so a database or permission outage stays visible to monitoring instead of being reported as many revoked tokens.
A tool returns nothing
The server never returns HTTP 404. A handler that finds no rows returnsnull, and tools/call wraps that as an ordinary result whose text is null under HTTP 200. Two causes look identical from the client:
- The row does not exist, or
class_iddoes not match it. - RLS filtered it out because the caller has no role in that class. Layer 1 lets the request through as long as the caller is staff somewhere, so a valid token pointed at the wrong class produces empty results rather than a 403.
Local server not reachable
- Confirm the stack is up with
npx supabase status, and that the URL is/functions/v1/mcp-serverrather than/mcp. - Confirm
mcp-serveris in the served set if you runnpx supabase functions servemanually with an explicit function list. - Pass your secrets. Functions started without
--env-filehave none of the variables above and fail in ways that look like authentication bugs. See Local setup. - Set
EDGE_FUNCTIONS_URLif a client needs the legacy SSEendpointevent. Without it the event is skipped and a warning is logged.
Source layout
Everything lives in the platform repo.