Skip to content

Commit 0fbb2d5

Browse files
committed
feat: add new exercises for user authentication and scopes, including problem and solution implementations
1 parent 992190b commit 0fbb2d5

File tree

300 files changed

+282995
-28
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

300 files changed

+282995
-28
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Authenticate Header

exercises/99.finished/99.solution/package-lock.json renamed to exercises/02.init/01.problem.authenticate/package-lock.json

File renamed without changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "exercises_02.init_01.problem.authenticate",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"test": "vitest run",
7+
"test:watch": "vitest --watch",
8+
"pretypecheck": "wrangler types ./types/worker-configuration.d.ts",
9+
"typecheck": "tsc",
10+
"build": "wrangler build",
11+
"dev": "mcp-dev",
12+
"dev:server": "cross-env wrangler dev --port ${PORT:-8787}",
13+
"inspect": "mcp-inspector"
14+
},
15+
"dependencies": {
16+
"@epic-web/epicme-db-client": "*",
17+
"@epic-web/invariant": "^1.0.0",
18+
"@modelcontextprotocol/sdk": "^1.17.4",
19+
"agents": "^0.0.113",
20+
"zod": "^3.25.67"
21+
},
22+
"devDependencies": {
23+
"@epic-web/config": "^1.21.3",
24+
"@epic-web/mcp-dev": "*",
25+
"@faker-js/faker": "^10.0.0",
26+
"@modelcontextprotocol/inspector": "0.16.5",
27+
"@types/node": "^24.3.0",
28+
"cross-env": "^10.0.0",
29+
"eslint": "^9.34.0",
30+
"execa": "^9.6.0",
31+
"prettier": "^3.6.2",
32+
"typescript": "^5.9.2",
33+
"vitest": "^3.2.4",
34+
"wrangler": "^4.33.0"
35+
},
36+
"prettier": "@epic-web/config/prettier",
37+
"license": "GPL-3.0-only"
38+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { EPIC_ME_AUTH_SERVER_URL } from './client.ts'
2+
3+
/**
4+
* This retrieves the protected resource configuration from the EpicMe server.
5+
* This is how the client knows where to request authorization from.
6+
*/
7+
export async function handleOAuthProtectedResourceRequest(request: Request) {
8+
// This server is the protected resource server, so we return our own configuration
9+
const resourceServerUrl = new URL(request.url)
10+
resourceServerUrl.pathname = '/mcp' // Point to the MCP endpoint
11+
12+
return Response.json({
13+
resource: resourceServerUrl.toString(),
14+
authorization_servers: [EPIC_ME_AUTH_SERVER_URL],
15+
})
16+
}
17+
18+
/**
19+
* Handles requests for OAuth authorization server metadata.
20+
* Fetches the metadata from the auth server and forwards it to the client.
21+
* This should only be used for backwards compatibility. Newer clients should
22+
* use `/.well-known/oauth-protected-resource/mcp` to discover the authorization
23+
* server and make this request directly to the authorization server instead.
24+
*/
25+
export async function handleOAuthAuthorizationServerRequest() {
26+
const metadataUrl = new URL(
27+
'/.well-known/oauth-authorization-server',
28+
EPIC_ME_AUTH_SERVER_URL,
29+
)
30+
31+
const response = await fetch(metadataUrl.toString())
32+
const data = await response.json()
33+
34+
return Response.json(data)
35+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { DBClient } from '@epic-web/epicme-db-client'
2+
3+
export const EPIC_ME_AUTH_SERVER_URL = 'http://localhost:7788'
4+
5+
export function getClient() {
6+
return new DBClient(EPIC_ME_AUTH_SERVER_URL)
7+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { type DBClient } from '@epic-web/epicme-db-client'
2+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3+
import {
4+
SetLevelRequestSchema,
5+
type LoggingLevel,
6+
} from '@modelcontextprotocol/sdk/types.js'
7+
import { McpAgent } from 'agents/mcp'
8+
import {
9+
handleOAuthAuthorizationServerRequest,
10+
handleOAuthProtectedResourceRequest,
11+
} from './auth.ts'
12+
import { getClient } from './client.ts'
13+
import { initializePrompts } from './prompts.ts'
14+
import { initializeResources } from './resources.ts'
15+
import { initializeTools } from './tools.ts'
16+
import { withCors } from './utils.ts'
17+
18+
type State = { loggingLevel: LoggingLevel }
19+
20+
export class EpicMeMCP extends McpAgent<Env, State> {
21+
db!: DBClient
22+
initialState: State = { loggingLevel: 'info' }
23+
server = new McpServer(
24+
{
25+
name: 'epicme',
26+
title: 'EpicMe Journal',
27+
version: '1.0.0',
28+
},
29+
{
30+
capabilities: {
31+
tools: { listChanged: true },
32+
resources: { listChanged: true, subscribe: true },
33+
completions: {},
34+
logging: {},
35+
prompts: { listChanged: true },
36+
},
37+
instructions: `
38+
EpicMe is a journaling app that allows users to write about and review their experiences, thoughts, and reflections.
39+
40+
These tools are the user's window into their journal. With these tools and your help, they can create, read, and manage their journal entries and associated tags.
41+
42+
You can also help users add tags to their entries and get all tags for an entry.
43+
`.trim(),
44+
},
45+
)
46+
47+
async init() {
48+
this.db = getClient()
49+
this.server.server.setRequestHandler(
50+
SetLevelRequestSchema,
51+
async (request) => {
52+
this.setState({ ...this.state, loggingLevel: request.params.level })
53+
return {}
54+
},
55+
)
56+
await initializeTools(this)
57+
await initializeResources(this)
58+
await initializePrompts(this)
59+
}
60+
}
61+
62+
export default {
63+
fetch: withCors({
64+
getCorsHeaders: (request) => {
65+
if (request.url.includes('/.well-known')) {
66+
return {
67+
'Access-Control-Allow-Origin': '*',
68+
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
69+
'Access-Control-Allow-Headers': 'mcp-protocol-version',
70+
'Cross-Origin-Resource-Policy': 'cross-origin',
71+
}
72+
}
73+
},
74+
handler: async (request, env, ctx) => {
75+
const url = new URL(request.url)
76+
77+
// for backwards compatibility with old clients that think we're the authorization server
78+
if (url.pathname === '/.well-known/oauth-authorization-server') {
79+
return handleOAuthAuthorizationServerRequest()
80+
}
81+
82+
if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) {
83+
return handleOAuthProtectedResourceRequest(request)
84+
}
85+
86+
if (url.pathname === '/mcp') {
87+
const mcp = EpicMeMCP.serve('/mcp', {
88+
binding: 'EPIC_ME_MCP_OBJECT',
89+
})
90+
return mcp.fetch(request, env, ctx)
91+
}
92+
93+
return new Response('Not found', { status: 404 })
94+
},
95+
}),
96+
} satisfies ExportedHandler<Env>
File renamed without changes.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { invariant } from '@epic-web/invariant'
2+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
3+
import { type EpicMeMCP } from './index.ts'
4+
5+
export async function initializeResources(agent: EpicMeMCP) {
6+
agent.server.registerResource(
7+
'tags',
8+
'epicme://tags',
9+
{
10+
title: 'Tags',
11+
description: 'All tags currently in the database',
12+
},
13+
async (uri) => {
14+
const tags = await agent.db.getTags()
15+
return {
16+
contents: [
17+
{
18+
mimeType: 'application/json',
19+
text: JSON.stringify(tags),
20+
uri: uri.toString(),
21+
},
22+
],
23+
}
24+
},
25+
)
26+
27+
agent.server.registerResource(
28+
'tag',
29+
new ResourceTemplate('epicme://tags/{id}', {
30+
complete: {
31+
async id(value) {
32+
const tags = await agent.db.getTags()
33+
return tags
34+
.map((tag) => tag.id.toString())
35+
.filter((id) => id.includes(value))
36+
},
37+
},
38+
list: async () => {
39+
const tags = await agent.db.getTags()
40+
return {
41+
resources: tags.map((tag) => ({
42+
name: tag.name,
43+
uri: `epicme://tags/${tag.id}`,
44+
mimeType: 'application/json',
45+
})),
46+
}
47+
},
48+
}),
49+
{
50+
title: 'Tag',
51+
description: 'A single tag with the given ID',
52+
},
53+
async (uri, { id }) => {
54+
const tag = await agent.db.getTag(Number(id))
55+
invariant(tag, `Tag with ID "${id}" not found`)
56+
return {
57+
contents: [
58+
{
59+
mimeType: 'application/json',
60+
text: JSON.stringify(tag),
61+
uri: uri.toString(),
62+
},
63+
],
64+
}
65+
},
66+
)
67+
68+
agent.server.registerResource(
69+
'entry',
70+
new ResourceTemplate('epicme://entries/{id}', {
71+
list: undefined,
72+
complete: {
73+
async id(value) {
74+
const entries = await agent.db.getEntries()
75+
return entries
76+
.map((entry) => entry.id.toString())
77+
.filter((id) => id.includes(value))
78+
},
79+
},
80+
}),
81+
{
82+
title: 'Journal Entry',
83+
description: 'A single journal entry with the given ID',
84+
},
85+
async (uri, { id }) => {
86+
const entry = await agent.db.getEntry(Number(id))
87+
invariant(entry, `Entry with ID "${id}" not found`)
88+
return {
89+
contents: [
90+
{
91+
mimeType: 'application/json',
92+
text: JSON.stringify(entry),
93+
uri: uri.toString(),
94+
},
95+
],
96+
}
97+
},
98+
)
99+
}
File renamed without changes.

0 commit comments

Comments
 (0)