MCP
A Model Context Protocol endpoint at /mcp, so an agent (Claude Code, a Claude
connector, any Streamable HTTP client) can work with your app. No sidecar process.
- Every tool is one REST call, run as the caller. A tool call becomes a request to your app's own API, made in-process as whoever signed in. API rules, request hooks, middleware and rate limits apply because it is the REST request. MCP makes no permission decisions of its own.
- The whole stock API is built in as
pb_*tools (records, files, auth, collections, settings, logs, crons, SQL, backups). An app that only uses the stock API needs nothing else. - Your own endpoints get tools the same way: write the REST endpoint, then the tool wrapper (
$mcp.addEndpointinpb_hooks, orAddEndpointfrom Go). - Sign-in with OAuth through OAuth Server: add
https://<host>/mcpto Claude and sign in in a browser, as a superuser or an app user. The connection stays signed in until you revoke it.
Install · Configure · Use · Integrations · Example
Install
MCP is built into pocketone and always on, with Tokens and
OAuth Server for sign-in and $mcp in pb_hooks. There's nothing to install or turn on.
In your own Go build, register it before the app starts. Register Tokens and OAuth Server too for OAuth sign-in, and
bind $mcp into the JS hooks if you use them:
app := pocketbase.New()
tokens.MustRegister(app, tokens.Config{})
oauthserver.MustRegister(app, oauthserver.Config{})
mcp.MustRegister(app, mcp.Config{})
jsvm.MustRegister(app, jsvm.Config{OnInit: func(vm *goja.Runtime) { mcp.BindJS(vm, app) }})
app.Start()
The order doesn't matter: MCP picks its sign-in on serve, and tools that pb_hooks declare before mcp.Register
wait for it. The module is github.com/pockethost/pocketone/ext/mcp.
Configure
There is no pocketone.yaml section, flag, environment variable or settings screen for MCP. In pocketone it serves
/mcp with every built-in tool, and:
- anyone who can sign in through OAuth Server can connect: superusers and app users of any auth collection, each with their own rights;
- an agent's connection doesn't expire; it lasts until you revoke it;
- the server's name for clients comes from Settings → Application → Application name.
A Go build can change the path, the tools, the caps and the sign-in (see Go config).
Scopes
MCP registers no scopes of its own. When an agent connects, the consent page offers Full access (the account's
rights, like a PocketBase auth token) or Only these, with a box for each of your
token scopes (scopes that only reach /_pocker/ routes, like deploy, aren't
offered unless the client asks for them). The protected resource metadata lists the same scopes in
scopes_supported, so a client can ask for some up front; the user can still change the ticks.
A connection limited to scopes is held to them exactly like an API token with those scopes on the REST API:
- Each tool call is checked against the scopes' paths and methods (or
Matchguards). A request they don't cover is refused with403, and the tool returns that as an error. - Collection rules see only those scopes in
@request.headers.x_token_scope_<name>. tools/listhides tools none of the scopes reaches, and the instructions name the scopes.
The token itself still works only on /mcp. To limit an agent further, sign it in as an account with fewer rights.
Behind a proxy
The OAuth resource URL must match the URL clients use. MCP builds it from the request's host, taking the scheme from
Settings → Application URL when its host is the same, else from TLS or X-Forwarded-Proto. If your proxy rewrites
the host, set Config.PublicURL in a Go build.
Browsers
Requests with an Origin header from another site get 403. Clients that send no Origin, such as Claude's servers
and Claude Code, pass. A Go build can allow more origins with Config.AllowedOrigins.
Use
Connect Claude Code
claude mcp add --transport http my-app https://<host>/mcp
claude mcp login my-app # or /mcp in a session
A browser opens on your app's sign-in page. Sign in (as an app user, or with Sign in as a superuser), check what the consent page says and click Allow. Claude and Claude Code can connect without registering, through their published client metadata documents.
For a Claude connector, add https://<host>/mcp as a custom connector and sign in the same way. Other MCP clients
register themselves with your app first.
Paste a token instead
A PocketBase auth token, or an API token not bound to any resource, works on /mcp as it does on the REST API, and
tool calls forward it unchanged. A scoped API token works only if one of its scopes covers /mcp; to limit an agent
to scopes, connect it through the consent page instead (see Scopes).
Who a tool call runs as
Whoever signed in. A superuser gets what the API gives superusers; an app user gets what your collection rules give them. Superuser-only tools are left out of other callers' tool list (that's presentation; the endpoint still decides).
See and revoke connected agents
Each sign-in creates an API token bound to /mcp, named "oauth: <client id> label; click Revoke to disconnect an agent.
Built-in tools
One per stock endpoint:
| Group | Tools | Who |
|---|---|---|
health |
pb_check_health |
everyone |
records |
pb_list_records (perPage 20 by default), pb_get_record, pb_create_record, pb_update_record, pb_delete_record, pb_run_batch |
per collection rules |
auth |
pb_list_auth_methods, pb_request_otp, pb_request_verification, pb_confirm_verification, pb_request_password_reset, pb_confirm_password_reset, pb_request_email_change, pb_confirm_email_change |
per collection |
files |
pb_download_file (protected files through a file token made as the caller; images as images, text as text, others as embedded data; up to 10 MB) |
per view rules |
collections |
pb_list_collections, pb_get_collection, pb_create_collection, pb_update_collection, pb_delete_collection, pb_truncate_collection, pb_import_collections, pb_get_collection_scaffolds, pb_list_oauth2_providers, pb_dry_run_view |
superusers |
settings |
pb_get_settings, pb_update_settings, pb_test_s3, pb_test_email, pb_generate_apple_client_secret |
superusers |
logs |
pb_list_logs, pb_get_log, pb_get_log_stats, pb_truncate_logs |
superusers |
crons |
pb_list_crons, pb_run_cron |
superusers |
sql |
pb_run_sql |
superusers |
backups |
pb_list_backups, pb_create_backup, pb_upload_backup, pb_download_backup, pb_delete_backup, pb_restore_backup |
superusers |
- Record create and update take field values in
data, with files as{"name", "base64", "type"}objects ("field+"appends,"field-"removes). pb_delete_collection,pb_truncate_collection,pb_delete_backupandpb_restore_backupneedconfirmset to the name.- Endpoints that hand out a new token (auth-with-*, auth-refresh, impersonate) and realtime (a stream) aren't tools.
- Each tool call counts against the API's rate limits, like any request. Batch sub-requests skip route middleware (PocketBase behaviour).
Add your own tools from pb_hooks
Write the endpoint as you would anyway, then declare its tool at the top level of a hooks file:
// pb_hooks/blog.pb.js
routerAdd('POST', '/api/blog/posts/{id}/publish', (e) => { /* … */ }, $apis.requireSuperuserAuth())
$mcp.addEndpoint({
name: 'blog_publish_post',
description: 'Publish a draft post and email subscribers.',
method: 'POST',
path: '/api/blog/posts/{id}/publish', // {id} comes from args.id
inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } },
// optional; request is the default mapping, return a changed copy
request: ({ args, auth, request }) => ({ ...request, body: { at: args.when } }),
// optional; throw for an error the model can read
response: ({ status, json }) => (status === 200 ? { published: json.id } : json),
})
$mcp.instructions('blog_publish_post also emails subscribers.')
- The tool runs the request as the caller, so the route's own auth check is what protects it. One tool serves every caller.
title,outputSchemaandannotationsare optional. Without annotations,GETtools are marked read-only andDELETEtools destructive.requestgets{args, auth, request}and returns{method, path, query, body, headers, files}(missing keys keep the default;$mcp.pathValue(v)escapes a path segment).responsegets{args, auth, status, headers, body, json, truncated}.- Both run one at a time on the VM that loaded the hooks and may use the file's top-level variables; the REST call itself runs outside it. Keep them small: the work belongs in the endpoint.
$mcp.tools()lists the registered tool names.
Agents are told to prefer the app's own tools: when an app has any, the instructions sent on connect say to use them
when one fits and fall back to pb_*, and the tool list shows them first. Put work that spans collections in a
custom endpoint (one transaction, the app's checks), not in a chain of pb_* calls. A wrapper can sit over a stock
endpoint too, to give it the app's meaning (for example notes_add_note over POST /api/collections/notes/records).
In the logs
Without OAuth Server on the app, MCP logs a warning on serve:
mcp: ext/oauth-server isn't registered; /mcp accepts pasted PocketBase auth tokens only, and clients can't sign in by themselves.
In pocketone it always is.
Integrations
Works with
| Extension | How they fit |
|---|---|
| OAuth Server | Signs MCP clients in. MCP points clients at it from the protected resource metadata, turns on its client self-registration, and makes tokens bound to /mcp never expire. Who may sign in comes from its Collections (default: superusers and every auth collection). |
| Tokens | The access tokens are API tokens bound to https://<host>/mcp (RFC 8707): they work nowhere else, and are listed and revoked under Settings → API tokens. |
| Your token scopes | On a full-access connection a tool call holds every scope you registered (except ones its collection can't hold): $tokens.requireScope guards pass, and @request.headers.x_token_scope_<name> is "1" in collection rules. On a connection limited to scopes it holds only those, and requests they don't cover are refused (see Scopes). |
| Pages | A pb_pages +api.js route is a REST endpoint like any other; declare its tool with $mcp.addEndpoint. |
| S3 Redirect | pb_download_file follows its one redirect to storage. |
| JS hooks | $mcp in pb_hooks (above). |
Under Pocker, a Go build registers MCP (with Tokens and OAuth Server) in each instance's
SetupApp; there's no extkit extension for it. The stock pocketone binary doesn't add it to pocker instances.
Tools from Go
An extension registered on the same app reaches the server with mcp.For(app):
server := mcp.For(app) // or the *mcp.Server that mcp.MustRegister returned
// the endpoint, as you'd write it anyway
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.POST("/api/blog/posts/{id}/publish", publish).Bind(apis.RequireSuperuserAuth())
return se.Next()
})
// the tool wrapper
server.MustAddEndpoint(mcp.Endpoint{
Name: "blog_publish_post", // lowercase <extension>_<verb>_<noun>; pb_ is the built-ins'
Title: "Publish post",
Description: "Publish a draft post and email subscribers.",
Method: http.MethodPost,
Path: "/api/blog/posts/{id}/publish", // {id} is filled from args.id
InputSchema: map[string]any{"type": "object", "required": []any{"id"},
"properties": map[string]any{"id": map[string]any{"type": "string"}}},
Annotations: mcp.Write, // default from the method: GET read-only, DELETE destructive
Request: nil, // optional: func(c *mcp.Call) (*mcp.Request, error)
Response: nil, // optional: func(c *mcp.Call, res *mcp.Response) (any, error)
})
server.AddInstructions("blog_publish_post also emails subscribers.")
The default mapping:
{name}path segments come from the argument of the same name, escaped. Empty,.and..are refused (the router would clean them into another route).- Other arguments go to the query string for
GET,HEADandDELETE, and to the JSON body for other methods. - An argument holding a file,
{"name": "a.png", "base64": "…", "type": "image/png"}or a list of them, makes the body multipart (other arguments ride along as@jsonPayload), the way PocketBase takes uploads. Up to 10 MB per file. - Arguments are checked against the schema's
required, top-leveltypes,enums andadditionalProperties: falsebefore the request is made; the endpoint validates the rest. - The result: 2xx JSON comes back as the result (also as
structuredContentwhen it's an object); other 2xx bodies as text; 4xx/5xx as a tool error with PocketBase's{status, message, data}, so the model can fix its call. JSON results are capped at 1 MB and say when they were cut.
Request sees the caller (c.Auth()) but can't change who the request runs as: an Authorization header it sets is
dropped. Build paths with c.Path(template) or mcp.PathValue, never by joining raw strings. c.Do makes another
request as the same caller (e.g. a file token before a download). Response may return any JSON-able value, a
string, or an mcp.Content list (images, text, embedded files). SuperuserOnly: true hides a tool from other
callers' tool list.
Tools from an extension
A PocketOne extension that adds tools names them <extension>_<verb>_<noun>: its name (with - as _), then at least two
lowercase words, such as notes_list_notes or oauth_server_revoke_client. Each has a description, and an input
schema, when it has one, is a JSON Schema object. The conformance test checks all of this
when the extension's test puts an MCP server on the test apps:
conformance.Run(t, newExtension, conformance.Options{Setup: func(app core.App) error {
_, err := mcp.Register(app, mcp.Config{})
return err
}})
How sign-in works
With Tokens and OAuth Server on the app, /mcp uses OAuth the way Claude connectors and Claude Code expect:
- A request without a token gets
401withWWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource/mcp". - The protected resource metadata names the app as its authorization server (OAuth Server's issuer). OAuth Server publishes the authorization server metadata, with PKCE S256, client metadata documents and dynamic registration.
- The client registers itself (Claude's published identity through a client metadata document, or dynamic registration), and the user signs in in a browser.
- The access token is an API token bound to
https://<host>/mcp. It carries the account's rights but works only on/mcp. Each MCP request makes a PocketBase auth token for the same account, and tool calls reach the REST API with it (as Pages'ctx.pb()does). For a token limited to scopes, those requests carry the scopes (tokens.WithScopes), and Tokens checks them as it would a scoped token's request.
Without OAuth Server, /mcp falls back to PocketBaseTokenAuth: pasted PocketBase auth tokens (Collections limits
which auth collections, default any), and the warning above. Config.Auth overrides the choice; mcp.OAuth{Issuer}
overrides the authorization server listed in the metadata.
Example
example/main.go runs one app with an MCP endpoint at /mcp: the built-in pb_* tools, a tool over a Go route and one over a JS route.
cd example
go run . serve
The example registers devusers, so the logins are [email protected] / password
(superuser) and [email protected] / password (user). It also registers Tokens and OAuth Server for OAuth sign-in,
and adds a tool over a Go route (example_get_whoami) and one over a JS route (example_list_recent_users, in
pb_hooks/mcp.pb.js). Then:
claude mcp add --transport http pb-example http://127.0.0.1:8090/mcp
claude mcp login pb-example
// Command example runs one PocketOne app with an MCP endpoint at /mcp: the built-in pb_* tools (the whole stock REST
// API), a tool over a Go route, and a tool over a JS route from pb_hooks/mcp.pb.js.
//
// cd ext/mcp/example && go run . serve
//
// Logins (ext/devusers, local testing only): dashboard [email protected] / password, app user [email protected] / password.
//
// Sign-in is OAuth (ext/oauth-server + ext/tokens): add the endpoint to Claude Code and sign in in the browser when it
// asks. Tool calls run as whoever signed in.
//
// claude mcp add --transport http pb-example http://127.0.0.1:8090/mcp
// claude mcp login pb-example (or /mcp in a session)
package main
import (
"log"
"net/http"
"ganigeorgiev.com/pocketbase/pocketbase"
"ganigeorgiev.com/pocketbase/pocketbase/apis"
"ganigeorgiev.com/pocketbase/pocketbase/core"
"ganigeorgiev.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/dop251/goja"
"github.com/pockethost/pocketone/ext/devusers"
"github.com/pockethost/pocketone/ext/mcp"
oauthserver "github.com/pockethost/pocketone/ext/oauth-server"
"github.com/pockethost/pocketone/ext/tokens"
)
func main() {
app := pocketbase.New()
devusers.Register(app) // local testing: superuser [email protected] and user [email protected], password "password"
// OAuth sign-in: API tokens + the authorization server. ext/mcp picks it up on serve and turns on dynamic client
// registration.
tokens.MustRegister(app, tokens.Config{})
oauthserver.MustRegister(app, oauthserver.Config{})
server := mcp.MustRegister(app, mcp.Config{
Name: "pb-example",
Description: "Example PocketOne app with ext/mcp.",
})
// A tool from Go: write the REST endpoint, then the tool wrapper. The tool runs the request as the caller, so
// the route's own auth check is what protects it.
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/example/whoami", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"collection": e.Auth.Collection().Name, "email": e.Auth.Email()})
}).Bind(apis.RequireAuth())
return se.Next()
})
server.MustAddEndpoint(mcp.Endpoint{
Name: "example_get_whoami",
Title: "Who am I",
Description: "The signed-in record's collection and email.",
Method: http.MethodGet,
Path: "/api/example/whoami",
})
jsvm.MustRegister(app, jsvm.Config{
OnInit: func(vm *goja.Runtime) { mcp.BindJS(vm, app) },
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Go config
| Field | Default | |
|---|---|---|
Path |
/mcp |
endpoint path |
Name, Version, Description, Icons |
app name slug, dev |
serverInfo |
Instructions |
generated | first paragraph of the instructions sent on connect |
PublicURL |
the request's host | origin used for the OAuth resource URL; set it when a proxy rewrites the host |
Auth |
OAuth{} with OAuth Server and Tokens, else PocketBaseTokenAuth{} |
the Authenticator |
BuiltinGroups |
all | which pb_* groups (mcp.BuiltinGroups) |
ReadOnlyBuiltins |
false |
keep only the read-only pb_* tools |
NoBuiltins |
false |
drop every pb_* tool |
MaxResponse |
1 MB | cap on a JSON result |
MaxFile |
10 MB | cap on one file uploaded or downloaded through a tool |
AllowedOrigins |
none | extra browser origins allowed besides the app's own |
The endpoint speaks MCP Streamable HTTP, stateless: each POST carries one JSON-RPC message and gets JSON back.
Protocol versions 2025-11-25, 2025-06-18 and 2025-03-26. Tools only for now.
License
MIT
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.