Skip to content

Commit 0e21b0d

Browse files
authored
Merge pull request #728 from devforth/feature/AdminForth/1970/please-introduce-same-oauth-au
2 parents 91be51b + 30353cd commit 0e21b0d

6 files changed

Lines changed: 167 additions & 13 deletions

File tree

‎adminforth/documentation/docs/tutorial/09-Plugins/30-mcp.md‎

Lines changed: 158 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
---
22
title: MCP Server
3-
description: "Expose AdminForth resources and actions to Codex, Claude Code, Gemini, and other MCP clients with per-user revocable auth secrets."
3+
description: "Expose AdminForth resources and actions to Codex, Claude Code, Gemini, and other MCP clients through OAuth sign-in or per-user revocable auth secrets."
44
slug: /tutorial/Plugins/mcp
55
---
66

77
# MCP Server
88

9-
The MCP plugin exposes AdminForth API methods as remote MCP tools. Every request runs as the AdminForth user who created the auth secret, so the same resource permissions, validation, and hooks apply.
9+
The MCP plugin exposes AdminForth API methods as remote MCP tools. An agent connects either through OAuth sign-in, where the user approves it in the browser, or with an auth secret the user creates. Every request runs as that AdminForth user, so the same resource permissions, validation, and hooks apply.
1010

1111
Endpoints marked with `agent: { hiddenFromAgents: true }` are not exposed. AdminForth and its plugins mark the endpoints the admin panel uses internally, such as login, two-factor authentication, passkeys, and agent chat. Mark your own endpoints the same way to keep them away from agents.
1212

@@ -16,9 +16,9 @@ Endpoints marked with `agent: { hiddenFromAgents: true }` are not exposed. Admin
1616
pnpm add @adminforth/mcp --save
1717
```
1818

19-
## MCP auth secret table
19+
## MCP connections table
2020

21-
Add a table for independently revocable MCP auth secrets. Only the SHA-256 secret hash is stored; the secret itself is returned once when it is created.
21+
Add a table for the user's MCP connections: auth secrets and OAuth connections, each revocable on its own. Only SHA-256 hashes are stored: for an auth secret the hash of the secret, which is returned once when it is created, and for an OAuth connection the hash of its refresh token.
2222

2323
```prisma title="./schema.prisma"
2424
model mcp_auth_secrets {
@@ -30,6 +30,7 @@ model mcp_auth_secrets {
3030
last_used_at DateTime?
3131
last_used_by_agent String?
3232
read_only Boolean @default(false)
33+
oauth_client_id String?
3334
3435
@@index([user_id])
3536
}
@@ -62,6 +63,7 @@ export default {
6263
{ name: 'last_used_at', type: AdminForthDataTypes.DATETIME, required: false },
6364
{ name: 'last_used_by_agent', type: AdminForthDataTypes.STRING, required: false },
6465
{ name: 'read_only', type: AdminForthDataTypes.BOOLEAN },
66+
{ name: 'oauth_client_id', type: AdminForthDataTypes.STRING, required: false },
6567
],
6668
options: {
6769
allowedActions: {
@@ -77,6 +79,8 @@ export default {
7779

7880
Register `mcpAuthSecrets` in the application's `resources` array.
7981

82+
`oauth_client_id` holds the client of an OAuth connection and stays empty for auth secrets. It is needed only for [OAuth sign-in](#oauth-sign-in); without it the plugin works with auth secrets alone. If the table already exists from an earlier plugin version, add only this nullable column with a migration to enable OAuth.
83+
8084
## Plugin setup
8185

8286
Add the plugin to `globalPlugins`:
@@ -88,7 +92,7 @@ new AdminForth({
8892
// ...
8993
globalPlugins: [
9094
new AdminForthMcpPlugin({
91-
adminPanelOrigin: 'https://your-adminforth-host.example.com',
95+
adminPanelOrigin: 'https://admin.example.com',
9296
authSecretResource: {
9397
resourceId: 'mcp_auth_secrets',
9498
idField: 'id',
@@ -99,35 +103,178 @@ new AdminForth({
99103
lastUsedAtField: 'last_used_at',
100104
lastUsedByAgentField: 'last_used_by_agent',
101105
readOnlyField: 'read_only',
106+
oauthClientIdField: 'oauth_client_id',
102107
},
103108
}),
104109
],
105110
});
106111
```
107112

108-
The MCP server identifies itself to agents as the AdminForth admin panel for the configured [`customization.brandName`](/docs/tutorial/Customization/branding/). Set `adminPanelOrigin` when the same agent uses several AdminForth installations. Pass only the public origin, such as `https://admin.example.com`; the plugin appends the existing AdminForth `baseUrl` path. The origin is optional and is not inferred from request headers.
113+
`oauthClientIdField` enables OAuth sign-in. Leave it out to keep only auth secrets, as in plugin versions before OAuth; then the dialog in **MCP Settings** shows only the auth secret form and no OAuth routes are added.
109114

110-
The plugin adds **MCP Settings** under the user profile. A user can create or revoke auth secrets there and copy the short setup prompt. Use one auth secret per agent.
115+
`adminPanelOrigin` is the public origin agents connect to, such as `https://admin.example.com`, without a path; the plugin appends the AdminForth `baseUrl`. It is required with OAuth sign-in, and the app does not start without it: the OAuth issuer and the MCP resource URL are built from it, and MCP clients reject a resource URL whose origin differs from the one they connected to. The origin is not inferred from request headers. The MCP server also identifies itself to agents by it and by [`customization.brandName`](/docs/tutorial/Customization/branding/), which tells several AdminForth installations apart.
111116

112117
The MCP endpoint is:
113118

114119
```text
115-
https://your-adminforth-host.example/adminapi/v1/mcp
120+
https://admin.example.com/adminapi/v1/mcp
116121
```
117122

118-
If `baseUrl` is configured, it appears before `/adminapi/v1/mcp`. Authenticate every request with:
123+
If `baseUrl` is configured, it appears before `/adminapi/v1/mcp`.
124+
125+
## Connecting an agent
126+
127+
The plugin adds **MCP Settings** under the user profile. With OAuth sign-in enabled, **Connect agent** opens a dialog with a tab per client:
128+
129+
- **Claude Code** – `claude mcp add --transport http <name> <url>`, then `/mcp` in Claude Code to sign in;
130+
- **Codex** – `codex mcp add <name> --url <url>`; Codex opens the browser to sign in, or run `codex mcp login <name>`;
131+
- **Other** – creates an auth secret for clients without OAuth sign-in.
132+
133+
`<name>` is `brandName` in lowercase with hyphens. The commands use the address the admin panel is opened at, so open it at its public address. The table below the button lists all connections of the user, auth secrets and OAuth ones, and revokes them. A revoked connection stops working on the agent's next request. Logging out, ending sessions and changing the password do not revoke MCP connections: revoke them in this table.
134+
135+
## OAuth sign-in
136+
137+
OAuth sign-in is enabled by `oauthClientIdField` together with `adminPanelOrigin`, see [Plugin setup](#plugin-setup).
138+
139+
When an agent calls the MCP endpoint without a token, the server answers `401` with a `WWW-Authenticate` header that leads the agent to the OAuth metadata. The agent then opens the browser on the consent page `/mcp-authorize`. If the user is not logged in, AdminForth shows its login page first, including two-factor authentication if it is enabled, and returns to the consent page. The consent page has a **Read only** checkbox, see [Read-only mode](#read-only-mode). After the user clicks **Allow**, the agent gets an access token and a refresh token and the connection appears in **MCP Settings** under the client's name.
140+
141+
The plugin is an OAuth 2.1 authorization server for public clients:
142+
143+
- authorization code flow with PKCE `S256`, with `authorization_code` and `refresh_token` grants;
144+
- access tokens are valid for 1 hour and are checked against their connection on every request;
145+
- a refresh token can be used once: every refresh returns a new one;
146+
- clients are identified by a [Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/): the `client_id` is an https URL, and the server fetches the client's name and allowed redirect URIs from it. Claude Code (`https://claude.ai/oauth/claude-code-client-metadata`) and Codex work this way. Dynamic Client Registration is not supported, so clients that only support it cannot sign in. In development, list such clients in `devOAuthClients`, see below.
147+
148+
Authorization requests and codes are signed with `ADMINFORTH_SECRET`; only the resulting connections are stored. Changing `ADMINFORTH_SECRET` invalidates pending sign-ins and access tokens; agents get new access tokens with their refresh tokens.
149+
150+
The plugin serves these routes:
151+
152+
| Route | Purpose |
153+
|---|---|
154+
| `<baseUrl>/adminapi/v1/mcp/oauth-protected-resource` | protected resource metadata, linked from `WWW-Authenticate` |
155+
| `<baseUrl>/adminapi/v1/mcp/.well-known/openid-configuration` | authorization server metadata |
156+
| `/.well-known/oauth-protected-resource<baseUrl>/adminapi/v1/mcp` | the same metadata at the host root |
157+
| `/.well-known/oauth-authorization-server<baseUrl>/adminapi/v1/mcp` | the same metadata at the host root |
158+
| `<baseUrl>/adminapi/v1/mcp/oauth/authorize`, `.../oauth/token`, `.../oauth/jwks` | OAuth endpoints |
159+
| `<baseUrl>/mcp-authorize` | consent page |
160+
161+
MCP clients look for the metadata at the host root first. The AdminForth SPA answers unknown paths with an HTML page, so the plugin serves the metadata there too. When the host root belongs to another application, those two routes are not needed: clients that get `404` there go on to the routes under `baseUrl`.
162+
163+
## Auth secrets
164+
165+
For clients without OAuth sign-in, create an auth secret in the **Connect agent** dialog, on the **Other** tab when OAuth is enabled, and send it with every request:
119166

120167
```http
121168
Authorization: Bearer afmcp_...
122169
```
123170

171+
The dialog shows the MCP URL, the header value and a short setup prompt for the agent. The secret is shown only once. Use one auth secret per agent.
172+
173+
## Local development
174+
175+
1. Create the table and the resource from [MCP connections table](#mcp-connections-table), including `oauth_client_id`, and run the migration.
176+
2. Set `oauthClientIdField: 'oauth_client_id'` in `authSecretResource` and `adminPanelOrigin` to the address you open the admin panel at, for example `http://localhost:3500`. OAuth works over plain `http` on `localhost`.
177+
3. Start the app without `NODE_ENV=production`.
178+
4. Open **MCP Settings → Connect agent**, copy the command from the **Claude Code** or **Codex** tab and run it, then sign in in the browser. The agent's callback goes to `http://localhost:<random port>/callback` on your computer.
179+
180+
The server fetches the metadata documents of Claude Code and Codex from `claude.ai` and `chatgpt.com`, so it needs outbound internet access.
181+
182+
To try a client that has no metadata document, describe it in `devOAuthClients`. A listed client is taken from the config instead of fetching its `client_id`; other clients are still fetched. Loopback redirect URIs match on any port.
183+
184+
```ts title="./index.ts"
185+
new AdminForthMcpPlugin({
186+
// ...
187+
adminPanelOrigin: 'http://localhost:3500',
188+
devOAuthClients: [
189+
{
190+
clientId: 'local-test',
191+
clientName: 'Local test',
192+
redirectUris: ['http://127.0.0.1/callback'],
193+
},
194+
],
195+
}),
196+
```
197+
198+
## Production
199+
200+
1. Create the table and the resource, or add the `oauth_client_id` column to an existing table, and run the migration. Apply the migration before the app starts with the column in the resource: AdminForth checks on start that every resource column exists in the table.
201+
2. Set `oauthClientIdField: 'oauth_client_id'` in `authSecretResource` and `adminPanelOrigin` to the public `https` origin agents connect to, exactly as they reach it: `https://admin.example.com`, not an internal address or a different port.
202+
3. Run the app with `NODE_ENV=production`. `devOAuthClients` are then ignored, with a warning in the log, and every client is identified by its metadata document.
203+
4. Let the reverse proxy forward to AdminForth:
204+
- `<baseUrl>/adminapi/v1/mcp` and every path below it;
205+
- `<baseUrl>/mcp-authorize`;
206+
- optionally `/.well-known/oauth-protected-resource<baseUrl>/adminapi/v1/mcp` and `/.well-known/oauth-authorization-server<baseUrl>/adminapi/v1/mcp`, if the host root belongs to AdminForth.
207+
5. Allow the server outbound `https` requests to the clients' metadata document hosts, such as `claude.ai` and `chatgpt.com`.
208+
6. Keep `ADMINFORTH_SECRET` stable between restarts and identical on all instances.
209+
210+
:::warning
211+
The server fetches `client_id` URLs chosen by whoever starts a sign-in. The plugin refuses URLs that resolve to private, loopback and other special-purpose addresses, does not follow redirects, and limits the document to 5 KB and 5 seconds. Fetch errors are written to the server log, not returned to the client.
212+
:::
213+
214+
## Troubleshooting
215+
216+
- **`Table 'mcp_auth_secrets' has no column 'oauth_client_id'`** on start – the resource declares the column, but the migration that adds it was not applied.
217+
- **`JSON Parse error: Unrecognized token '<'`** in the agent – something answers the metadata URLs at the host root with an HTML page. Forward the `/.well-known/...` routes from the table above to AdminForth, or make the host root return `404` for them.
218+
- **`Could not fetch the client metadata document`** – the server could not download the client's `client_id` URL; the reason is in the server log. Check outbound network access, or list the client in `devOAuthClients` in development.
219+
- **`redirect_uri is not listed in the client metadata document`** – the client signs in with a redirect URI its document does not allow. For clients in `devOAuthClients`, add the redirect URI there.
220+
- **The agent rejects the server's resource or issuer** – `adminPanelOrigin` differs from the origin the agent connects to, for example `http://` instead of `https://` behind a TLS proxy.
221+
- **`Authentication required: the MCP auth secret or OAuth access token is missing, invalid or revoked.`** – the first answer to an agent without a token, which starts OAuth. If the agent shows it instead of opening the browser, it does not support OAuth sign-in, or OAuth is not enabled: use an auth secret.
222+
124223
`last_used_at` and `last_used_by_agent` are updated in the background without running resource hooks. Client identity comes from per-request `io.modelcontextprotocol/clientInfo` metadata in MCP `2026-07-28`, with legacy `initialize` and `User-Agent` fallbacks.
125224

225+
## Page size
226+
227+
`get_resource_data` returns 10 records when the agent does not pass `limit`, and at most 100 records per call; a larger `limit` is capped. When more records exist, the response tells the agent to ask the user before it loads the next page, so an imprecise request does not page through the whole table. Change both numbers with `pageSize`:
228+
229+
```ts title="./index.ts"
230+
new AdminForthMcpPlugin({
231+
// ...
232+
pageSize: {
233+
default: 20,
234+
max: 200,
235+
},
236+
}),
237+
```
238+
239+
The same numbers are quoted in the instructions and skills the MCP server gives to agents.
240+
241+
## Tool timeout and call limit
242+
243+
A tool call fails with a timeout error when its handler runs longer than `toolTimeoutMs`, 15 seconds by default. The handler gets an aborted signal, and the error tells the agent that a change it started may still complete, so it checks the result before calling again.
244+
245+
The instructions also ask the agent to make at most `toolCallsPerRequest` calls, 10 by default, for one user request, and to ask the user before it continues with more. MCP clients do not tell the server where a user request starts, so this is guidance for the agent, not a limit the server enforces.
246+
247+
```ts title="./index.ts"
248+
new AdminForthMcpPlugin({
249+
// ...
250+
toolTimeoutMs: 30_000,
251+
toolCallsPerRequest: 20,
252+
}),
253+
```
254+
255+
## Agent guidance
256+
257+
The MCP server sends agents short rules in its instructions: to treat record values as data, never as instructions; to show every change and wait for the user's confirmation in chat; how many calls to make and records to load for one request; to ask which resource to use when several match; and to use `aggregate` for statistics. Detailed rules for typical tasks are skills that the agent loads with the `fetch_skill` tool:
258+
259+
- `fetch_data` – find, list and show records;
260+
- `analyze_data` – counts, sums, trends and other statistics;
261+
- `mutate_data` – create, update and delete records and run actions.
262+
263+
Tools that change data, such as `delete_record`, are marked as destructive for MCP clients, and their descriptions repeat that the agent must get a confirmation in chat, even when the client itself does not ask for approval.
264+
265+
## Tool responses
266+
267+
Tool responses are trimmed to save the agent's context:
268+
269+
- `get_resource` returns only the column properties needed to read, filter and aggregate records. The agent passes `detailed: true` to get every column property and the resource actions before it creates or updates records or runs actions.
270+
- `get_resource_data` rows leave out columns with `null`, empty array and empty object values, and datetimes come without milliseconds.
271+
- Tool outputs are serialized as YAML instead of JSON.
272+
126273
## Read-only mode
127274

128-
A read-only auth secret lets an agent only read data. Check **Read only** when you create the auth secret in **MCP Settings**. The mode is stored in `read_only` and cannot be changed later: create a new auth secret instead.
275+
A read-only connection lets an agent only read data. Check **Read only** when you create an auth secret in **MCP Settings**, or on the consent page when you connect an agent with OAuth sign-in. The mode is stored in `read_only` and cannot be changed later: create a new auth secret or connect the agent again instead.
129276

130-
To make every auth secret read-only, set `readOnly: true` in the plugin options. The **Read only** checkbox is then hidden, and new auth secrets are stored as read-only, so they stay read-only if you remove the option later:
277+
To make every connection read-only, set `readOnly: true` in the plugin options. The **Read only** checkbox is then hidden, the consent page says that the agent can only read data, and new auth secrets and OAuth connections are stored as read-only, so they stay read-only if you remove the option later:
131278

132279
```ts
133280
new AdminForthMcpPlugin({

‎dev-demo/.env.local‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,5 @@ CH_MIGRATIONS_PASSWORD=demo
1010

1111
AWS_REGION=eu-central-1
1212
AWS_BUCKET_NAME=tmpbucket-adminforth
13-
AWS_ACCESS_KEY_ID=your_access_key_id
14-
AWS_SECRET_ACCESS_KEY=your_secret_access_key
1513

1614
USE_S3=false

‎dev-demo/globalPlugins.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const globalPlugins = [
4646
dashboardConfigsResourceId: 'dashboard_configs',
4747
}),
4848
new AdminForthMcpPlugin({
49+
adminPanelOrigin: 'http://localhost:3123',
4950
authSecretResource: {
5051
resourceId: 'mcp_auth_secrets',
5152
idField: 'id',
@@ -56,7 +57,11 @@ export const globalPlugins = [
5657
lastUsedAtField: 'last_used_at',
5758
lastUsedByAgentField: 'last_used_by_agent',
5859
readOnlyField: 'read_only',
60+
oauthClientIdField: 'oauth_client_id',
5961
},
62+
devOAuthClients: [
63+
{ clientId: 'local-test', clientName: 'Local test', redirectUris: ['http://127.0.0.1/callback'] },
64+
],
6065
}),
6166
new AdminForthAgent({
6267
placeholderMessages: async ({ adminUser, httpExtra }) => {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "mcp_auth_secrets" ADD COLUMN "oauth_client_id" TEXT;

‎dev-demo/migrations/prisma/sqlite/schema.prisma‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ model mcp_auth_secrets {
6666
last_used_at DateTime?
6767
last_used_by_agent String?
6868
read_only Boolean @default(false)
69+
oauth_client_id String?
6970
7071
@@index([user_id])
7172
}

‎dev-demo/resources/mcpAuthSecrets.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default {
1515
{ name: 'last_used_at', type: AdminForthDataTypes.DATETIME, required: false },
1616
{ name: 'last_used_by_agent', type: AdminForthDataTypes.STRING, required: false },
1717
{ name: 'read_only', type: AdminForthDataTypes.BOOLEAN },
18+
{ name: 'oauth_client_id', type: AdminForthDataTypes.STRING, required: false },
1819
],
1920
options: {
2021
allowedActions: {

0 commit comments

Comments
 (0)