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"