Skip to main content

MCP Server

Pawtograder exposes a Model Context Protocol (MCP) server as a Supabase Edge Function. The request handler and tool registry live in supabase/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.
http://127.0.0.1:54321/mcp is a different server. That URL belongs to the Supabase CLI itself, which exposes your local Postgres to an AI assistant, and it is the one that shows up in npx supabase status. Pawtograder’s MCP server answers only at /functions/v1/mcp-server. See MCP endpoints.

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

Every tools/call result wraps the handler’s return value in one text block (index.ts:1891-1908):
The text is 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 and Accept 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 the Authorization: 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):
An MCP token carries mcp:write as well as mcp:read. No tool requires it, because all 16 declare requiredScope: "mcp:read", but the scope is in the token. Do not read “read-only tools” as “read-only token” when you reason about what a leaked token could do after a write tool ships.
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:
  1. Reject a missing Authorization header, then a header that is not exactly two space-separated parts with a case-insensitive bearer first part.
  2. Verify the token signature and required claims with verifyApiToken.
  3. Look up jti in revoked_token_ids.
  4. Query user_roles with the service role for any non-disabled row where the user holds instructor or grader. Zero rows gives 403 User must be an instructor or grader in at least one class (MCPAuth.ts:351).
Step 4 is not per class. It asks only whether you are an instructor or grader in at least one class anywhere on the deployment, and it does not look at the class_id the request is about. There is no per-class check before a tool runs: requireClassAccess and hasAccessToClass exist only in supabase/functions/mcp-server/auth.ts, which nothing imports. Per-class scoping comes entirely from layer 2.

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 users table.
  • is_private_profile is 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 only id, name, avatar_url, and class_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).

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:
  1. Add the descriptor (name, description, inputSchema, requiredScope) to TOOLS at index.ts:90. This alone only makes the tool appear in tools/list.
  2. Add a case to the switch in executeTool (index.ts:1741-1850). Without it the call reaches default and throws Tool not implemented: <name>, which reaches the client as JSON-RPC -32000.
Read data through 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-disabled instructor or grader role 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.
Check token creation first. If the dialog cannot issue a token, the deployment is misconfigured and nothing about your client is worth debugging yet.

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 returns null, 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_id does 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.
Confirm in the web app, as that user, that the data is visible at all.

Local server not reachable

  • Confirm the stack is up with npx supabase status, and that the URL is /functions/v1/mcp-server rather than /mcp.
  • Confirm mcp-server is in the served set if you run npx supabase functions serve manually with an explicit function list.
  • Pass your secrets. Functions started without --env-file have none of the variables above and fail in ways that look like authentication bugs. See Local setup.
  • Set EDGE_FUNCTIONS_URL if a client needs the legacy SSE endpoint event. Without it the event is skipped and a warning is logged.

Source layout

Everything lives in the platform repo.
supabase/functions/mcp-server/auth.ts implements an OAuth flow plus requireClassAccess and hasAccessToClass, and nothing imports it. It contradicts the API-token model the server actually uses. Do not read it as documentation of current behavior.