Skip to content

Commit a5a2717

Browse files
committed
feat(playground): add apiKey and auth in MCP
1 parent 706f599 commit a5a2717

File tree

16 files changed

+1548
-16
lines changed

16 files changed

+1548
-16
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,5 @@ coverage
5454
Network Trash Folder
5555
Temporary Items
5656
.apdisk
57-
.turbo
57+
.turbo
58+
apps/playground/.data
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
---
2+
title: Authentication
3+
description: Secure your MCP endpoints with Bearer token authentication.
4+
navigation:
5+
icon: i-lucide-shield-check
6+
seo:
7+
title: MCP Authentication
8+
description: Learn how to add authentication to your MCP handlers using API keys and middleware.
9+
---
10+
11+
## Overview
12+
13+
MCP endpoints can be secured using Bearer token authentication. This guide shows how to:
14+
15+
1. Generate and manage API keys for users
16+
2. Validate tokens in MCP middleware
17+
3. Access user context in your tools
18+
4. Configure MCP clients with authentication
19+
20+
::callout{icon="i-lucide-triangle-alert" color="warning"}
21+
**Important:** MCP middleware should **not throw errors** for missing or invalid authentication. Throwing a 401 error will cause MCP clients to enter OAuth discovery mode, looking for `.well-known/oauth-*` endpoints that don't exist. Instead, use a "soft" approach that sets context when auth succeeds but allows requests to continue otherwise.
22+
::
23+
24+
## Using Better Auth API Keys
25+
26+
If you're using [Better Auth](https://www.better-auth.com), you can leverage the built-in [API Key plugin](https://www.better-auth.com/docs/plugins/api-key) for a complete solution.
27+
28+
### Server Configuration
29+
30+
Add the API Key plugin to your Better Auth configuration:
31+
32+
```typescript [server/utils/auth.ts]
33+
import { betterAuth } from 'better-auth'
34+
import { apiKey } from 'better-auth/plugins'
35+
36+
export const auth = betterAuth({
37+
// ... your existing config
38+
plugins: [
39+
apiKey({
40+
rateLimit: {
41+
enabled: false, // Disable rate limiting (if not needed)
42+
},
43+
}),
44+
],
45+
})
46+
```
47+
48+
::callout{icon="i-lucide-info" color="info"}
49+
The API Key plugin has rate limiting enabled by default. Disable it for development or configure appropriate limits for production.
50+
::
51+
52+
### Client Configuration
53+
54+
Add the client plugin to use API key methods:
55+
56+
```typescript [composables/auth.ts]
57+
import { createAuthClient } from 'better-auth/client'
58+
import { apiKeyClient } from 'better-auth/client/plugins'
59+
60+
const client = createAuthClient({
61+
plugins: [
62+
apiKeyClient(),
63+
],
64+
})
65+
66+
// Create an API key
67+
const { data } = await client.apiKey.create({ name: 'My MCP Key' })
68+
console.log(data.key) // Save this - only shown once!
69+
70+
// List API keys
71+
const { data: keys } = await client.apiKey.list()
72+
73+
// Delete an API key
74+
await client.apiKey.delete({ keyId: 'key-id' })
75+
```
76+
77+
### Helper Function
78+
79+
Create a helper function that validates API keys without throwing errors:
80+
81+
```typescript [server/utils/auth.ts]
82+
export async function getApiKeyUser(event: H3Event) {
83+
const authHeader = getHeader(event, 'authorization')
84+
85+
if (!authHeader?.startsWith('Bearer ')) {
86+
return null
87+
}
88+
89+
const key = authHeader.slice(7)
90+
const result = await auth.api.verifyApiKey({ body: { key } })
91+
92+
if (!result.valid || !result.key) {
93+
return null
94+
}
95+
96+
const user = await db.query.user.findFirst({
97+
where: (users, { eq }) => eq(users.id, result.key!.userId),
98+
})
99+
100+
if (!user) {
101+
return null
102+
}
103+
104+
return { user, apiKey: result.key }
105+
}
106+
```
107+
108+
### MCP Handler with Authentication
109+
110+
Create a handler that sets user context when a valid API key is provided:
111+
112+
```typescript [server/mcp/index.ts]
113+
export default defineMcpHandler({
114+
middleware: async (event) => {
115+
const result = await getApiKeyUser(event)
116+
if (result) {
117+
event.context.user = result.user
118+
event.context.userId = result.user.id
119+
}
120+
},
121+
})
122+
```
123+
124+
This approach:
125+
- Sets `event.context.user` and `event.context.userId` when authentication succeeds
126+
- Allows requests without authentication to continue (context will be `undefined`)
127+
- Tools can check for user context and handle unauthorized access as needed
128+
129+
### Using Context in Tools
130+
131+
Your tools can access the authenticated user from `event.context`. For tools that require authentication, check if the user exists:
132+
133+
```typescript [server/mcp/tools/create-todo.ts]
134+
export default defineMcpTool({
135+
name: 'create_todo',
136+
description: 'Create a new todo for the authenticated user',
137+
inputSchema: {
138+
title: z.string().describe('The title of the todo'),
139+
content: z.string().optional().describe('Optional description or content'),
140+
},
141+
handler: async ({ title, content }) => {
142+
const event = useEvent()
143+
const userId = event.context.userId as string
144+
145+
if (!userId) {
146+
return textResult('Authentication required. Please provide a valid API key.')
147+
}
148+
149+
const [todo] = await db.insert(schema.todos).values({
150+
title,
151+
content: content || null,
152+
userId,
153+
createdAt: new Date(),
154+
updatedAt: new Date(),
155+
}).returning()
156+
157+
return textResult(`Todo created: ${todo.title}`)
158+
},
159+
})
160+
```
161+
162+
For tools that work with or without authentication:
163+
164+
```typescript [server/mcp/tools/list-todos.ts]
165+
export default defineMcpTool({
166+
name: 'list_todos',
167+
description: 'List all todos for the authenticated user',
168+
inputSchema: {},
169+
handler: async () => {
170+
const event = useEvent()
171+
const userId = event.context.userId as string
172+
173+
if (!userId) {
174+
return textResult('Authentication required. Please provide a valid API key.')
175+
}
176+
177+
const todos = await db.query.todos.findMany({
178+
where: (todos, { eq }) => eq(todos.userId, userId),
179+
})
180+
181+
return textResult(JSON.stringify(todos, null, 2))
182+
},
183+
})
184+
```
185+
186+
::callout{icon="i-lucide-info" color="info"}
187+
Remember to enable `asyncContext` in your Nuxt config to use `useEvent()`:
188+
189+
```typescript [nuxt.config.ts]
190+
export default defineNuxtConfig({
191+
nitro: {
192+
experimental: {
193+
asyncContext: true,
194+
},
195+
},
196+
})
197+
```
198+
::
199+
200+
## Custom Token Validation
201+
202+
If you're not using Better Auth, you can implement your own token validation. Remember to use a soft approach that doesn't throw errors:
203+
204+
```typescript [server/utils/auth.ts]
205+
import { createHash } from 'node:crypto'
206+
207+
export async function getTokenUser(event: H3Event) {
208+
const authHeader = getHeader(event, 'authorization')
209+
210+
if (!authHeader?.startsWith('Bearer ')) {
211+
return null
212+
}
213+
214+
const token = authHeader.slice(7)
215+
const tokenHash = createHash('sha256').update(token).digest('hex')
216+
217+
// Look up the token in your database
218+
const apiToken = await db.query.apiTokens.findFirst({
219+
where: (tokens, { eq }) => eq(tokens.hash, tokenHash),
220+
})
221+
222+
if (!apiToken) {
223+
return null
224+
}
225+
226+
// Check expiration
227+
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
228+
return null
229+
}
230+
231+
return { userId: apiToken.userId }
232+
}
233+
```
234+
235+
```typescript [server/mcp/index.ts]
236+
export default defineMcpHandler({
237+
middleware: async (event) => {
238+
const result = await getTokenUser(event)
239+
if (result) {
240+
event.context.userId = result.userId
241+
}
242+
},
243+
})
244+
```
245+
246+
## Configuring MCP Clients
247+
248+
### Cursor
249+
250+
Add your MCP server to `.cursor/mcp.json`:
251+
252+
```json [.cursor/mcp.json]
253+
{
254+
"mcpServers": {
255+
"my-app": {
256+
"url": "http://localhost:3000/mcp",
257+
"headers": {
258+
"Authorization": "Bearer your-api-key-here"
259+
}
260+
}
261+
}
262+
}
263+
```
264+
265+
### Claude Desktop
266+
267+
Add to your Claude Desktop configuration:
268+
269+
```json [claude_desktop_config.json]
270+
{
271+
"mcpServers": {
272+
"my-app": {
273+
"url": "http://localhost:3000/mcp",
274+
"headers": {
275+
"Authorization": "Bearer your-api-key-here"
276+
}
277+
}
278+
}
279+
}
280+
```
281+
282+
### Other Clients
283+
284+
Most MCP clients support custom headers. Check your client's documentation for the exact configuration format.
285+
286+
## TypeScript
287+
288+
For type-safe context, extend the H3 event context:
289+
290+
```typescript [server/types.ts]
291+
declare module 'h3' {
292+
interface H3EventContext {
293+
user?: {
294+
id: string
295+
name: string
296+
email: string
297+
}
298+
userId?: string
299+
}
300+
}
301+
```
302+
303+
## Security Best Practices
304+
305+
1. **Always hash tokens** - Store hashed tokens in your database, not plaintext
306+
2. **Set expiration dates** - API keys should expire to limit exposure
307+
3. **Use HTTPS** - Always use HTTPS in production to protect tokens in transit
308+
4. **Implement rate limiting** - Prevent abuse with request limits per key
309+
5. **Allow key revocation** - Users should be able to delete compromised keys
310+
6. **Log key usage** - Track when keys are used for security auditing
311+
312+
## Next Steps
313+
314+
- [Middleware](/advanced/middleware) - Learn more about middleware options
315+
- [Handlers](/core-concepts/handlers) - Create custom authenticated handlers
316+
- [TypeScript](/advanced/typescript) - Type-safe context definitions

apps/playground/app/composables/auth.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { defu } from 'defu'
22
import { createAuthClient } from 'better-auth/client'
3+
import { apiKeyClient } from 'better-auth/client/plugins'
34
import type {
45
InferSessionFromClient,
56
InferUserFromClient,
6-
ClientOptions,
7+
BetterAuthClientOptions,
78
} from 'better-auth/client'
89
import type { RouteLocationRaw } from 'vue-router'
910

@@ -21,14 +22,17 @@ export function useAuth() {
2122
fetchOptions: {
2223
headers,
2324
},
25+
plugins: [
26+
apiKeyClient(),
27+
],
2428
})
2529

2630
const options = defu(useRuntimeConfig().public.auth as Partial<RuntimeAuthConfig>, {
2731
redirectUserTo: '/app',
2832
redirectGuestTo: '/',
2933
})
30-
const session = useState<InferSessionFromClient<ClientOptions> | null>('auth:session', () => null)
31-
const user = useState<InferUserFromClient<ClientOptions> | null>('auth:user', () => null)
34+
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null)
35+
const user = useState<InferUserFromClient<BetterAuthClientOptions> | null>('auth:user', () => null)
3236
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false)
3337

3438
const fetchSession = async () => {

0 commit comments

Comments
 (0)