diff --git a/epicshop/epic-me/app/routes.ts b/epicshop/epic-me/app/routes.ts
index c871dc8..46bd1dd 100644
--- a/epicshop/epic-me/app/routes.ts
+++ b/epicshop/epic-me/app/routes.ts
@@ -3,7 +3,7 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'
export default [
index('routes/index.tsx'),
route('/authorize', 'routes/authorize.tsx'),
- route('/whoami', 'routes/whoami.tsx'),
+ route('/healthcheck', 'routes/healthcheck.tsx'),
route('/db-api', 'routes/db-api.tsx'),
route('/introspect', 'routes/introspect.tsx'),
] satisfies RouteConfig
diff --git a/epicshop/epic-me/app/routes/healthcheck.tsx b/epicshop/epic-me/app/routes/healthcheck.tsx
new file mode 100644
index 0000000..8e2938d
--- /dev/null
+++ b/epicshop/epic-me/app/routes/healthcheck.tsx
@@ -0,0 +1,3 @@
+export async function loader() {
+ return new Response('ok')
+}
diff --git a/epicshop/epic-me/app/routes/index.tsx b/epicshop/epic-me/app/routes/index.tsx
index 871a455..9ae29d2 100644
--- a/epicshop/epic-me/app/routes/index.tsx
+++ b/epicshop/epic-me/app/routes/index.tsx
@@ -189,7 +189,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{user.entries.length > 0 ? (
-
+
Entries ({user.entries.length})
@@ -270,7 +270,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{user.tags.length > 0 && (
-
+
Tags ({user.tags.length})
diff --git a/epicshop/epic-me/app/routes/whoami.tsx b/epicshop/epic-me/app/routes/whoami.tsx
deleted file mode 100644
index 7dc3433..0000000
--- a/epicshop/epic-me/app/routes/whoami.tsx
+++ /dev/null
@@ -1,249 +0,0 @@
-import { redirect } from 'react-router'
-import { type Route } from './+types/whoami'
-
-export function meta() {
- return [
- { title: 'Who Am I - Epic Me' },
- { name: 'description', content: 'Current user information' },
- ]
-}
-
-export async function loader({ context }: Route.LoaderArgs) {
- try {
- // Get user info from OAuth props (automatically validated by OAuth provider)
- const userId = context.cloudflare.ctx.props.userId
-
- if (!userId) {
- throw redirect('/')
- }
-
- // Get user from database using the email from OAuth props
- const user = await context.db.getUserById(Number(userId))
-
- if (!user) {
- throw redirect('/')
- }
-
- // Get user's entries and tags
- const entries = await context.db.getEntries(user.id)
- const tags = await context.db.getTags(user.id)
-
- // Get full entry data including createdAt for each entry
- const fullEntries = await Promise.all(
- entries.map(async (entry: any) => {
- const fullEntry = await context.db.getEntry(user.id, entry.id)
- if (!fullEntry) {
- return null // Ignore entries that aren't found
- }
-
- return {
- ...entry,
- createdAt: fullEntry.createdAt,
- formattedDate: new Date(
- fullEntry.createdAt * 1000,
- ).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- }),
- }
- }),
- )
-
- // Filter out null entries
- const validEntries = fullEntries.filter(
- (entry): entry is NonNullable
=> entry !== null,
- )
-
- // Get full tag data including createdAt for each tag
- const fullTags = await Promise.all(
- tags.map(async (tag: any) => {
- const fullTag = await context.db.getTag(user.id, tag.id)
- if (!fullTag) {
- return null // Ignore tags that aren't found
- }
-
- return {
- ...tag,
- createdAt: fullTag.createdAt,
- formattedDate: new Date(fullTag.createdAt * 1000).toLocaleDateString(
- 'en-US',
- {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- },
- ),
- }
- }),
- )
-
- // Filter out null tags
- const validTags = fullTags.filter(
- (tag: any): tag is NonNullable => tag !== null,
- )
-
- return {
- user: { ...user, entries: validEntries, tags: validTags },
- }
- } catch (error) {
- console.error('Error in whoami loader:', error)
- throw redirect('/')
- }
-}
-
-export default function WhoAmI({ loaderData }: Route.ComponentProps) {
- const { user } = loaderData
-
- return (
-
-
-
- Current User
-
-
-
-
-
-
- {user.email.charAt(0).toUpperCase()}
-
-
-
-
- {user.email}
-
-
- User ID: {user.id}
-
-
-
-
-
- {user.entries.length > 0 ? (
-
-
- Your Entries ({user.entries.length})
-
-
- {user.entries.map((entry) => (
-
-
-
-
- {entry.title}
-
-
-
-
- {entry.formattedDate}
-
-
-
- {entry.tagCount} tag
- {entry.tagCount !== 1 ? 's' : ''}
-
-
- ID: {entry.id}
-
-
-
-
-
- ))}
-
-
- ) : (
-
-
- Your Entries
-
-
-
-
- No entries yet
-
-
-
- )}
-
- {user.tags.length > 0 && (
-
-
- Your Tags ({user.tags.length})
-
-
- {user.tags.map((tag) => (
-
-
- {tag.name}
-
-
-
- {tag.formattedDate}
-
-
- ))}
-
-
- )}
-
-
- )
-}
diff --git a/exercises/01.start/01.problem/README.mdx b/exercises/01.start/01.solution/README.mdx
similarity index 100%
rename from exercises/01.start/01.problem/README.mdx
rename to exercises/01.start/01.solution/README.mdx
diff --git a/exercises/01.start/01.problem/package.json b/exercises/01.start/01.solution/package.json
similarity index 93%
rename from exercises/01.start/01.problem/package.json
rename to exercises/01.start/01.solution/package.json
index d18fa0e..8ebe4c7 100644
--- a/exercises/01.start/01.problem/package.json
+++ b/exercises/01.start/01.solution/package.json
@@ -1,5 +1,5 @@
{
- "name": "exercises_01.start_01.problem",
+ "name": "exercises_01.start_01.solution",
"private": true,
"type": "module",
"scripts": {
diff --git a/exercises/01.start/01.problem/src/client.ts b/exercises/01.start/01.solution/src/client.ts
similarity index 100%
rename from exercises/01.start/01.problem/src/client.ts
rename to exercises/01.start/01.solution/src/client.ts
diff --git a/exercises/01.start/01.problem/src/index.test.ts b/exercises/01.start/01.solution/src/index.test.ts
similarity index 100%
rename from exercises/01.start/01.problem/src/index.test.ts
rename to exercises/01.start/01.solution/src/index.test.ts
diff --git a/exercises/01.start/01.problem/src/index.ts b/exercises/01.start/01.solution/src/index.ts
similarity index 100%
rename from exercises/01.start/01.problem/src/index.ts
rename to exercises/01.start/01.solution/src/index.ts
diff --git a/exercises/01.start/01.problem/src/prompts.ts b/exercises/01.start/01.solution/src/prompts.ts
similarity index 100%
rename from exercises/01.start/01.problem/src/prompts.ts
rename to exercises/01.start/01.solution/src/prompts.ts
diff --git a/exercises/01.start/01.problem/src/resources.ts b/exercises/01.start/01.solution/src/resources.ts
similarity index 100%
rename from exercises/01.start/01.problem/src/resources.ts
rename to exercises/01.start/01.solution/src/resources.ts
diff --git a/exercises/01.start/01.problem/src/sampling.ts b/exercises/01.start/01.solution/src/sampling.ts
similarity index 100%
rename from exercises/01.start/01.problem/src/sampling.ts
rename to exercises/01.start/01.solution/src/sampling.ts
diff --git a/exercises/01.start/01.problem/src/tools.ts b/exercises/01.start/01.solution/src/tools.ts
similarity index 100%
rename from exercises/01.start/01.problem/src/tools.ts
rename to exercises/01.start/01.solution/src/tools.ts
diff --git a/exercises/01.start/01.problem/tsconfig.json b/exercises/01.start/01.solution/tsconfig.json
similarity index 100%
rename from exercises/01.start/01.problem/tsconfig.json
rename to exercises/01.start/01.solution/tsconfig.json
diff --git a/exercises/01.start/01.problem/types/reset.d.ts b/exercises/01.start/01.solution/types/reset.d.ts
similarity index 100%
rename from exercises/01.start/01.problem/types/reset.d.ts
rename to exercises/01.start/01.solution/types/reset.d.ts
diff --git a/exercises/02.start/01.problem/tsconfig.json b/exercises/02.start/01.problem/tsconfig.json
deleted file mode 100644
index be52957..0000000
--- a/exercises/02.start/01.problem/tsconfig.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "extends": ["@epic-web/config/typescript"],
- "include": ["types/**/*.d.ts", "src/**/*.ts"]
-}
diff --git a/exercises/02.start/01.problem/README.mdx b/exercises/02.start/01.solution/README.mdx
similarity index 100%
rename from exercises/02.start/01.problem/README.mdx
rename to exercises/02.start/01.solution/README.mdx
diff --git a/exercises/02.start/01.problem/package-lock.json b/exercises/02.start/01.solution/package-lock.json
similarity index 100%
rename from exercises/02.start/01.problem/package-lock.json
rename to exercises/02.start/01.solution/package-lock.json
diff --git a/exercises/02.start/01.problem/package.json b/exercises/02.start/01.solution/package.json
similarity index 93%
rename from exercises/02.start/01.problem/package.json
rename to exercises/02.start/01.solution/package.json
index 297d39e..3469e12 100644
--- a/exercises/02.start/01.problem/package.json
+++ b/exercises/02.start/01.solution/package.json
@@ -1,5 +1,5 @@
{
- "name": "exercises_02.start_01.problem",
+ "name": "exercises_02.start_01.solution",
"private": true,
"type": "module",
"scripts": {
@@ -25,6 +25,7 @@
"@types/node": "^24.1.0",
"cross-env": "^10.0.0",
"eslint": "^9.32.0",
+ "execa": "^9.5.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
diff --git a/exercises/02.start/01.problem/src/client.ts b/exercises/02.start/01.solution/src/client.ts
similarity index 100%
rename from exercises/02.start/01.problem/src/client.ts
rename to exercises/02.start/01.solution/src/client.ts
diff --git a/exercises/02.start/01.problem/src/index.ts b/exercises/02.start/01.solution/src/index.ts
similarity index 100%
rename from exercises/02.start/01.problem/src/index.ts
rename to exercises/02.start/01.solution/src/index.ts
diff --git a/exercises/02.start/01.problem/src/prompts.ts b/exercises/02.start/01.solution/src/prompts.ts
similarity index 100%
rename from exercises/02.start/01.problem/src/prompts.ts
rename to exercises/02.start/01.solution/src/prompts.ts
diff --git a/exercises/02.start/01.problem/src/resources.ts b/exercises/02.start/01.solution/src/resources.ts
similarity index 100%
rename from exercises/02.start/01.problem/src/resources.ts
rename to exercises/02.start/01.solution/src/resources.ts
diff --git a/exercises/02.start/01.problem/src/sampling.ts b/exercises/02.start/01.solution/src/sampling.ts
similarity index 100%
rename from exercises/02.start/01.problem/src/sampling.ts
rename to exercises/02.start/01.solution/src/sampling.ts
diff --git a/exercises/02.start/01.problem/src/tools.ts b/exercises/02.start/01.solution/src/tools.ts
similarity index 100%
rename from exercises/02.start/01.problem/src/tools.ts
rename to exercises/02.start/01.solution/src/tools.ts
diff --git a/exercises/02.start/01.solution/test/globalSetup.ts b/exercises/02.start/01.solution/test/globalSetup.ts
new file mode 100644
index 0000000..a3f3300
--- /dev/null
+++ b/exercises/02.start/01.solution/test/globalSetup.ts
@@ -0,0 +1,248 @@
+import { execa } from 'execa'
+import getPort from 'get-port'
+import { type TestProject } from 'vitest/node'
+
+declare module 'vitest' {
+ export interface ProvidedContext {
+ mcpServerPort: number
+ }
+}
+
+export default async function setup(project: TestProject) {
+ const mcpServerPort = await getPort()
+
+ project.provide('mcpServerPort', mcpServerPort)
+
+ let appServerProcess: ReturnType | null = null
+ let mcpServerProcess: ReturnType | null = null
+
+ // Buffers to store output for potential error display
+ const appServerOutput: Array = []
+ const mcpServerOutput: Array = []
+
+ /**
+ * Wait for a server to be ready by monitoring its output for a specific text pattern
+ */
+ async function waitForServerReady({
+ process: childProcess,
+ textMatch,
+ name,
+ outputBuffer,
+ }: {
+ process: ReturnType | null
+ textMatch: string
+ name: string
+ outputBuffer: Array
+ }) {
+ if (!childProcess) return
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ childProcess?.kill()
+ reject(new Error(`${name} failed to start within 10 seconds`))
+ }, 10_000)
+
+ function searchForMatch(data: Buffer) {
+ const str = data.toString()
+ outputBuffer.push(str)
+ if (str.includes(textMatch)) {
+ clearTimeout(timeout)
+ // Remove the listeners after finding the match
+ childProcess?.stdout?.removeListener('data', searchForMatch)
+ childProcess?.stderr?.removeListener('data', searchForMatch)
+ resolve()
+ }
+ }
+ childProcess?.stdout?.on('data', searchForMatch)
+ childProcess?.stderr?.on('data', searchForMatch)
+
+ childProcess?.on('error', (err) => {
+ clearTimeout(timeout)
+ reject(err)
+ })
+
+ childProcess?.on('exit', (code) => {
+ if (code !== 0) {
+ clearTimeout(timeout)
+ reject(new Error(`${name} exited with code ${code}`))
+ }
+ })
+ })
+ }
+
+ /**
+ * Display buffered output when there's a failure
+ */
+ function displayBufferedOutput() {
+ if (appServerOutput.length > 0) {
+ console.log('=== App Server Output ===')
+ for (const line of appServerOutput) {
+ process.stdout.write(line)
+ }
+ }
+ if (mcpServerOutput.length > 0) {
+ console.log('=== MCP Server Output ===')
+ for (const line of mcpServerOutput) {
+ process.stdout.write(line)
+ }
+ }
+ }
+
+ async function startAppServerIfNecessary() {
+ const isAppRunning = await fetch('http://localhost:7788/healthcheck').catch(
+ () => ({ ok: false }),
+ )
+ if (isAppRunning.ok) {
+ return
+ }
+
+ const rootDir = process.cwd().replace(/exercises\/.*$/, '')
+
+ // Start the app server from the root directory
+ console.log(`Starting app server on port 7788...`)
+ appServerProcess = execa(
+ 'npm',
+ [
+ 'run',
+ 'dev',
+ '--prefix',
+ './epicshop/epic-me',
+ '--',
+ '--clearScreen=false',
+ '--strictPort',
+ ],
+ {
+ cwd: rootDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+ }
+
+ async function startServers() {
+ console.log('Starting servers...')
+
+ // Start app server if necessary
+ await startAppServerIfNecessary()
+
+ // Start the MCP server from the exercise directory
+ console.log(`Starting MCP server on port ${mcpServerPort}...`)
+ mcpServerProcess = execa(
+ 'npx',
+ ['wrangler', 'dev', '--port', mcpServerPort.toString()],
+ {
+ cwd: process.cwd(),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: {
+ ...process.env,
+ PORT: mcpServerPort.toString(),
+ },
+ },
+ )
+
+ try {
+ // Wait for both servers to be ready simultaneously
+ await Promise.all([
+ appServerProcess
+ ? waitForServerReady({
+ process: appServerProcess,
+ textMatch: ':7788',
+ name: '[APP-SERVER]',
+ outputBuffer: appServerOutput,
+ })
+ : Promise.resolve(),
+ waitForServerReady({
+ process: mcpServerProcess,
+ textMatch: `:${mcpServerPort.toString()}`,
+ name: '[MCP-SERVER]',
+ outputBuffer: mcpServerOutput,
+ }),
+ ])
+
+ console.log('Servers started successfully')
+ } catch (error) {
+ // Display buffered output on failure
+ displayBufferedOutput()
+ throw error
+ }
+ }
+
+ async function cleanup() {
+ console.log('Cleaning up servers...')
+
+ const cleanupPromises: Array> = []
+
+ if (mcpServerProcess && !mcpServerProcess.killed) {
+ cleanupPromises.push(
+ (async () => {
+ mcpServerProcess.kill('SIGTERM')
+ // Give it 2 seconds to gracefully shutdown, then force kill
+ const timeout = setTimeout(() => {
+ if (mcpServerProcess && !mcpServerProcess.killed) {
+ mcpServerProcess.kill('SIGKILL')
+ }
+ }, 2000)
+
+ try {
+ await mcpServerProcess
+ } catch {
+ // Process was killed, which is expected
+ } finally {
+ clearTimeout(timeout)
+ }
+ })(),
+ )
+ }
+
+ if (appServerProcess && !appServerProcess.killed) {
+ cleanupPromises.push(
+ (async () => {
+ appServerProcess.kill('SIGTERM')
+ // Give it 2 seconds to gracefully shutdown, then force kill
+ const timeout = setTimeout(() => {
+ if (appServerProcess && !appServerProcess.killed) {
+ appServerProcess.kill('SIGKILL')
+ }
+ }, 2000)
+
+ try {
+ await appServerProcess
+ } catch {
+ // Process was killed, which is expected
+ } finally {
+ clearTimeout(timeout)
+ }
+ })(),
+ )
+ }
+
+ // Wait for all cleanup to complete, but with an overall timeout
+ try {
+ await Promise.race([
+ Promise.all(cleanupPromises),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Cleanup timeout')), 5000),
+ ),
+ ])
+ } catch (error) {
+ console.warn(
+ 'Cleanup warning:',
+ error instanceof Error ? error.message : String(error),
+ )
+ // Force kill any remaining processes
+ if (mcpServerProcess && !mcpServerProcess.killed) {
+ mcpServerProcess.kill('SIGKILL')
+ }
+ if (appServerProcess && !appServerProcess.killed) {
+ appServerProcess.kill('SIGKILL')
+ }
+ }
+
+ console.log('Servers cleaned up')
+ }
+
+ // Start servers and wait for them to be ready before returning
+ await startServers()
+
+ // Return cleanup function
+ return cleanup
+}
diff --git a/exercises/02.start/01.solution/test/index.test.ts b/exercises/02.start/01.solution/test/index.test.ts
new file mode 100644
index 0000000..0935e84
--- /dev/null
+++ b/exercises/02.start/01.solution/test/index.test.ts
@@ -0,0 +1,36 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { test, expect, inject } from 'vitest'
+
+const mcpServerPort = inject('mcpServerPort')
+
+async function setupClient() {
+ const client = new Client(
+ {
+ name: 'EpicMeTester',
+ version: '1.0.0',
+ },
+ { capabilities: {} },
+ )
+
+ const transport = new StreamableHTTPClientTransport(
+ new URL(`http://localhost:${mcpServerPort}/mcp`),
+ )
+
+ await client.connect(transport)
+
+ return {
+ client,
+ async [Symbol.asyncDispose]() {
+ await client.transport?.close()
+ },
+ }
+}
+
+test('listing tools works', async () => {
+ await using setup = await setupClient()
+ const { client } = setup
+
+ const result = await client.listTools()
+ expect(result.tools.length).toBeGreaterThan(0)
+})
diff --git a/exercises/02.start/01.solution/tsconfig.json b/exercises/02.start/01.solution/tsconfig.json
new file mode 100644
index 0000000..646080c
--- /dev/null
+++ b/exercises/02.start/01.solution/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": ["@epic-web/config/typescript"],
+ "include": [
+ "types/**/*.d.ts",
+ "src/**/*.ts",
+ "test/**/*.ts",
+ "vitest.config.ts"
+ ]
+}
diff --git a/exercises/02.start/01.problem/types/reset.d.ts b/exercises/02.start/01.solution/types/reset.d.ts
similarity index 100%
rename from exercises/02.start/01.problem/types/reset.d.ts
rename to exercises/02.start/01.solution/types/reset.d.ts
diff --git a/exercises/02.start/01.problem/types/worker-configuration.d.ts b/exercises/02.start/01.solution/types/worker-configuration.d.ts
similarity index 100%
rename from exercises/02.start/01.problem/types/worker-configuration.d.ts
rename to exercises/02.start/01.solution/types/worker-configuration.d.ts
diff --git a/exercises/02.start/01.solution/vitest.config.ts b/exercises/02.start/01.solution/vitest.config.ts
new file mode 100644
index 0000000..78233c4
--- /dev/null
+++ b/exercises/02.start/01.solution/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ globalSetup: './test/globalSetup.ts',
+ },
+})
diff --git a/exercises/02.start/01.problem/wrangler.jsonc b/exercises/02.start/01.solution/wrangler.jsonc
similarity index 100%
rename from exercises/02.start/01.problem/wrangler.jsonc
rename to exercises/02.start/01.solution/wrangler.jsonc
diff --git a/exercises/99.finished/99.solution/package.json b/exercises/99.finished/99.solution/package.json
index d68263e..f12b4f0 100644
--- a/exercises/99.finished/99.solution/package.json
+++ b/exercises/99.finished/99.solution/package.json
@@ -7,7 +7,8 @@
"pretypecheck": "wrangler types ./types/worker-configuration.d.ts",
"typecheck": "tsc",
"build": "wrangler build",
- "dev": "cross-env wrangler dev --port ${PORT:-8787}",
+ "dev": "mcp-dev",
+ "dev:server": "cross-env wrangler dev --port ${PORT:-8787}",
"inspect": "mcp-inspector"
},
"dependencies": {
@@ -24,6 +25,7 @@
"@types/node": "^24.1.0",
"cross-env": "^10.0.0",
"eslint": "^9.32.0",
+ "execa": "^9.5.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
diff --git a/exercises/99.finished/99.solution/test/globalSetup.ts b/exercises/99.finished/99.solution/test/globalSetup.ts
new file mode 100644
index 0000000..a3f3300
--- /dev/null
+++ b/exercises/99.finished/99.solution/test/globalSetup.ts
@@ -0,0 +1,248 @@
+import { execa } from 'execa'
+import getPort from 'get-port'
+import { type TestProject } from 'vitest/node'
+
+declare module 'vitest' {
+ export interface ProvidedContext {
+ mcpServerPort: number
+ }
+}
+
+export default async function setup(project: TestProject) {
+ const mcpServerPort = await getPort()
+
+ project.provide('mcpServerPort', mcpServerPort)
+
+ let appServerProcess: ReturnType | null = null
+ let mcpServerProcess: ReturnType | null = null
+
+ // Buffers to store output for potential error display
+ const appServerOutput: Array = []
+ const mcpServerOutput: Array = []
+
+ /**
+ * Wait for a server to be ready by monitoring its output for a specific text pattern
+ */
+ async function waitForServerReady({
+ process: childProcess,
+ textMatch,
+ name,
+ outputBuffer,
+ }: {
+ process: ReturnType | null
+ textMatch: string
+ name: string
+ outputBuffer: Array
+ }) {
+ if (!childProcess) return
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ childProcess?.kill()
+ reject(new Error(`${name} failed to start within 10 seconds`))
+ }, 10_000)
+
+ function searchForMatch(data: Buffer) {
+ const str = data.toString()
+ outputBuffer.push(str)
+ if (str.includes(textMatch)) {
+ clearTimeout(timeout)
+ // Remove the listeners after finding the match
+ childProcess?.stdout?.removeListener('data', searchForMatch)
+ childProcess?.stderr?.removeListener('data', searchForMatch)
+ resolve()
+ }
+ }
+ childProcess?.stdout?.on('data', searchForMatch)
+ childProcess?.stderr?.on('data', searchForMatch)
+
+ childProcess?.on('error', (err) => {
+ clearTimeout(timeout)
+ reject(err)
+ })
+
+ childProcess?.on('exit', (code) => {
+ if (code !== 0) {
+ clearTimeout(timeout)
+ reject(new Error(`${name} exited with code ${code}`))
+ }
+ })
+ })
+ }
+
+ /**
+ * Display buffered output when there's a failure
+ */
+ function displayBufferedOutput() {
+ if (appServerOutput.length > 0) {
+ console.log('=== App Server Output ===')
+ for (const line of appServerOutput) {
+ process.stdout.write(line)
+ }
+ }
+ if (mcpServerOutput.length > 0) {
+ console.log('=== MCP Server Output ===')
+ for (const line of mcpServerOutput) {
+ process.stdout.write(line)
+ }
+ }
+ }
+
+ async function startAppServerIfNecessary() {
+ const isAppRunning = await fetch('http://localhost:7788/healthcheck').catch(
+ () => ({ ok: false }),
+ )
+ if (isAppRunning.ok) {
+ return
+ }
+
+ const rootDir = process.cwd().replace(/exercises\/.*$/, '')
+
+ // Start the app server from the root directory
+ console.log(`Starting app server on port 7788...`)
+ appServerProcess = execa(
+ 'npm',
+ [
+ 'run',
+ 'dev',
+ '--prefix',
+ './epicshop/epic-me',
+ '--',
+ '--clearScreen=false',
+ '--strictPort',
+ ],
+ {
+ cwd: rootDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+ }
+
+ async function startServers() {
+ console.log('Starting servers...')
+
+ // Start app server if necessary
+ await startAppServerIfNecessary()
+
+ // Start the MCP server from the exercise directory
+ console.log(`Starting MCP server on port ${mcpServerPort}...`)
+ mcpServerProcess = execa(
+ 'npx',
+ ['wrangler', 'dev', '--port', mcpServerPort.toString()],
+ {
+ cwd: process.cwd(),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: {
+ ...process.env,
+ PORT: mcpServerPort.toString(),
+ },
+ },
+ )
+
+ try {
+ // Wait for both servers to be ready simultaneously
+ await Promise.all([
+ appServerProcess
+ ? waitForServerReady({
+ process: appServerProcess,
+ textMatch: ':7788',
+ name: '[APP-SERVER]',
+ outputBuffer: appServerOutput,
+ })
+ : Promise.resolve(),
+ waitForServerReady({
+ process: mcpServerProcess,
+ textMatch: `:${mcpServerPort.toString()}`,
+ name: '[MCP-SERVER]',
+ outputBuffer: mcpServerOutput,
+ }),
+ ])
+
+ console.log('Servers started successfully')
+ } catch (error) {
+ // Display buffered output on failure
+ displayBufferedOutput()
+ throw error
+ }
+ }
+
+ async function cleanup() {
+ console.log('Cleaning up servers...')
+
+ const cleanupPromises: Array> = []
+
+ if (mcpServerProcess && !mcpServerProcess.killed) {
+ cleanupPromises.push(
+ (async () => {
+ mcpServerProcess.kill('SIGTERM')
+ // Give it 2 seconds to gracefully shutdown, then force kill
+ const timeout = setTimeout(() => {
+ if (mcpServerProcess && !mcpServerProcess.killed) {
+ mcpServerProcess.kill('SIGKILL')
+ }
+ }, 2000)
+
+ try {
+ await mcpServerProcess
+ } catch {
+ // Process was killed, which is expected
+ } finally {
+ clearTimeout(timeout)
+ }
+ })(),
+ )
+ }
+
+ if (appServerProcess && !appServerProcess.killed) {
+ cleanupPromises.push(
+ (async () => {
+ appServerProcess.kill('SIGTERM')
+ // Give it 2 seconds to gracefully shutdown, then force kill
+ const timeout = setTimeout(() => {
+ if (appServerProcess && !appServerProcess.killed) {
+ appServerProcess.kill('SIGKILL')
+ }
+ }, 2000)
+
+ try {
+ await appServerProcess
+ } catch {
+ // Process was killed, which is expected
+ } finally {
+ clearTimeout(timeout)
+ }
+ })(),
+ )
+ }
+
+ // Wait for all cleanup to complete, but with an overall timeout
+ try {
+ await Promise.race([
+ Promise.all(cleanupPromises),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Cleanup timeout')), 5000),
+ ),
+ ])
+ } catch (error) {
+ console.warn(
+ 'Cleanup warning:',
+ error instanceof Error ? error.message : String(error),
+ )
+ // Force kill any remaining processes
+ if (mcpServerProcess && !mcpServerProcess.killed) {
+ mcpServerProcess.kill('SIGKILL')
+ }
+ if (appServerProcess && !appServerProcess.killed) {
+ appServerProcess.kill('SIGKILL')
+ }
+ }
+
+ console.log('Servers cleaned up')
+ }
+
+ // Start servers and wait for them to be ready before returning
+ await startServers()
+
+ // Return cleanup function
+ return cleanup
+}
diff --git a/exercises/99.finished/99.solution/test/index.test.ts b/exercises/99.finished/99.solution/test/index.test.ts
new file mode 100644
index 0000000..0935e84
--- /dev/null
+++ b/exercises/99.finished/99.solution/test/index.test.ts
@@ -0,0 +1,36 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { test, expect, inject } from 'vitest'
+
+const mcpServerPort = inject('mcpServerPort')
+
+async function setupClient() {
+ const client = new Client(
+ {
+ name: 'EpicMeTester',
+ version: '1.0.0',
+ },
+ { capabilities: {} },
+ )
+
+ const transport = new StreamableHTTPClientTransport(
+ new URL(`http://localhost:${mcpServerPort}/mcp`),
+ )
+
+ await client.connect(transport)
+
+ return {
+ client,
+ async [Symbol.asyncDispose]() {
+ await client.transport?.close()
+ },
+ }
+}
+
+test('listing tools works', async () => {
+ await using setup = await setupClient()
+ const { client } = setup
+
+ const result = await client.listTools()
+ expect(result.tools.length).toBeGreaterThan(0)
+})
diff --git a/exercises/99.finished/99.solution/tsconfig.json b/exercises/99.finished/99.solution/tsconfig.json
index be52957..646080c 100644
--- a/exercises/99.finished/99.solution/tsconfig.json
+++ b/exercises/99.finished/99.solution/tsconfig.json
@@ -1,4 +1,9 @@
{
"extends": ["@epic-web/config/typescript"],
- "include": ["types/**/*.d.ts", "src/**/*.ts"]
+ "include": [
+ "types/**/*.d.ts",
+ "src/**/*.ts",
+ "test/**/*.ts",
+ "vitest.config.ts"
+ ]
}
diff --git a/exercises/99.finished/99.solution/vitest.config.ts b/exercises/99.finished/99.solution/vitest.config.ts
new file mode 100644
index 0000000..78233c4
--- /dev/null
+++ b/exercises/99.finished/99.solution/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ globalSetup: './test/globalSetup.ts',
+ },
+})
diff --git a/package-lock.json b/package-lock.json
index 129b51c..c3d46bc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -51,6 +51,26 @@
},
"exercises/01.start/01.problem": {
"name": "exercises_01.start_01.problem",
+ "extraneous": true,
+ "license": "GPL-3.0-only",
+ "dependencies": {
+ "@epic-web/invariant": "^1.0.0",
+ "@modelcontextprotocol/sdk": "^1.17.1",
+ "zod": "^3.25.67"
+ },
+ "devDependencies": {
+ "@epic-web/config": "^1.21.1",
+ "@epic-web/mcp-dev": "*",
+ "@faker-js/faker": "^9.9.0",
+ "@modelcontextprotocol/inspector": "^0.16.2",
+ "@types/node": "^24.1.0",
+ "tsx": "^4.20.3",
+ "typescript": "^5.9.2",
+ "vitest": "^3.2.4"
+ }
+ },
+ "exercises/01.start/01.solution": {
+ "name": "exercises_01.start_01.solution",
"license": "GPL-3.0-only",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
@@ -70,6 +90,31 @@
},
"exercises/02.start/01.problem": {
"name": "exercises_02.start_01.problem",
+ "extraneous": true,
+ "license": "GPL-3.0-only",
+ "dependencies": {
+ "@epic-web/epicme-db-client": "*",
+ "@epic-web/invariant": "^1.0.0",
+ "@modelcontextprotocol/sdk": "^1.17.0",
+ "agents": "^0.0.109",
+ "zod": "^3.25.67"
+ },
+ "devDependencies": {
+ "@epic-web/config": "^1.21.3",
+ "@faker-js/faker": "^9.9.0",
+ "@modelcontextprotocol/inspector": "^0.16.2",
+ "@types/node": "^24.1.0",
+ "cross-env": "^10.0.0",
+ "eslint": "^9.32.0",
+ "execa": "^9.5.1",
+ "prettier": "^3.6.2",
+ "typescript": "^5.9.2",
+ "vitest": "^3.2.4",
+ "wrangler": "^4.26.0"
+ }
+ },
+ "exercises/02.start/01.solution": {
+ "name": "exercises_02.start_01.solution",
"license": "GPL-3.0-only",
"dependencies": {
"@epic-web/epicme-db-client": "*",
@@ -85,6 +130,7 @@
"@types/node": "^24.1.0",
"cross-env": "^10.0.0",
"eslint": "^9.32.0",
+ "execa": "^9.5.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
@@ -108,6 +154,7 @@
"@types/node": "^24.1.0",
"cross-env": "^10.0.0",
"eslint": "^9.32.0",
+ "execa": "^9.5.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
@@ -5534,12 +5581,12 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
- "node_modules/exercises_01.start_01.problem": {
- "resolved": "exercises/01.start/01.problem",
+ "node_modules/exercises_01.start_01.solution": {
+ "resolved": "exercises/01.start/01.solution",
"link": true
},
- "node_modules/exercises_02.start_01.problem": {
- "resolved": "exercises/02.start/01.problem",
+ "node_modules/exercises_02.start_01.solution": {
+ "resolved": "exercises/02.start/01.solution",
"link": true
},
"node_modules/exercises_99.finished_99.solution": {
diff --git a/tsconfig.json b/tsconfig.json
index 2d7b651..70acd51 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,10 +5,10 @@
],
"references": [
{
- "path": "exercises/01.start/01.problem"
+ "path": "exercises/01.start/01.solution"
},
{
- "path": "exercises/02.start/01.problem"
+ "path": "exercises/02.start/01.solution"
},
{
"path": "exercises/99.finished/99.solution"