Skip to content

Commit b38895a

Browse files
authored
feat(mcp-student): add student practice MCP (#5090)
1 parent ee4dbd5 commit b38895a

71 files changed

Lines changed: 5106 additions & 191 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
name: Build Docker image for mcp-student (prd)
2+
3+
on:
4+
push:
5+
tags: ['v*.*.*']
6+
7+
concurrency:
8+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}
9+
cancel-in-progress: true
10+
11+
env:
12+
REGISTRY: ghcr.io
13+
IMAGE_NAME: ${{ github.repository }}/mcp-student
14+
15+
jobs:
16+
build-arm:
17+
runs-on: ubuntu-24.04-arm
18+
permissions:
19+
contents: read
20+
packages: write
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v4
24+
25+
- name: Set up QEMU
26+
uses: docker/setup-qemu-action@v3
27+
28+
- name: Set up Docker Buildx
29+
uses: docker/setup-buildx-action@v3
30+
31+
- name: Log into registry ${{ env.REGISTRY }}
32+
uses: docker/login-action@v3
33+
with:
34+
registry: ${{ env.REGISTRY }}
35+
username: ${{ github.actor }}
36+
password: ${{ secrets.GITHUB_TOKEN }}
37+
38+
- name: Extract Docker metadata
39+
id: meta
40+
uses: docker/metadata-action@v5
41+
with:
42+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-arm
43+
44+
- name: Build and push Docker image
45+
uses: docker/build-push-action@v5
46+
with:
47+
context: .
48+
file: apps/mcp-student/Dockerfile
49+
push: ${{ github.event_name != 'pull_request' }}
50+
tags: ${{ steps.meta.outputs.tags }}
51+
platforms: linux/arm64
52+
no-cache: true
53+
labels: ${{ steps.meta.outputs.labels }}
54+
55+
build-amd:
56+
runs-on: ubuntu-latest
57+
permissions:
58+
contents: read
59+
packages: write
60+
steps:
61+
- name: Checkout repository
62+
uses: actions/checkout@v4
63+
64+
- name: Set up QEMU
65+
uses: docker/setup-qemu-action@v3
66+
67+
- name: Set up Docker Buildx
68+
uses: docker/setup-buildx-action@v3
69+
70+
- name: Log into registry ${{ env.REGISTRY }}
71+
uses: docker/login-action@v3
72+
with:
73+
registry: ${{ env.REGISTRY }}
74+
username: ${{ github.actor }}
75+
password: ${{ secrets.GITHUB_TOKEN }}
76+
77+
- name: Extract Docker metadata
78+
id: meta
79+
uses: docker/metadata-action@v5
80+
with:
81+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-amd
82+
83+
- name: Build and push Docker image
84+
uses: docker/build-push-action@v5
85+
with:
86+
context: .
87+
file: apps/mcp-student/Dockerfile
88+
push: ${{ github.event_name != 'pull_request' }}
89+
tags: ${{ steps.meta.outputs.tags }}
90+
platforms: linux/amd64
91+
labels: ${{ steps.meta.outputs.labels }}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
name: Build Docker image for mcp-student (stg)
2+
3+
on:
4+
push:
5+
branches:
6+
- 'v3'
7+
- 'v3*'
8+
pull_request:
9+
types: [opened, synchronize, reopened, ready_for_review]
10+
paths:
11+
- 'apps/mcp-student/**'
12+
- 'packages/graphql/**'
13+
- 'packages/types/**'
14+
- 'packages/util/**'
15+
- 'package.json'
16+
- 'pnpm-lock.yaml'
17+
- 'turbo.json'
18+
- '.github/workflows/v3_mcp-student**'
19+
20+
concurrency:
21+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}
22+
cancel-in-progress: true
23+
24+
env:
25+
REGISTRY: ghcr.io
26+
IMAGE_NAME: ${{ github.repository }}/mcp-student
27+
28+
jobs:
29+
build-arm:
30+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
31+
runs-on: ubuntu-24.04-arm
32+
permissions:
33+
contents: read
34+
packages: write
35+
steps:
36+
- name: Checkout repository
37+
uses: actions/checkout@v4
38+
39+
- name: Set up QEMU
40+
uses: docker/setup-qemu-action@v3
41+
42+
- name: Set up Docker Buildx
43+
uses: docker/setup-buildx-action@v3
44+
45+
- name: Log into registry ${{ env.REGISTRY }}
46+
uses: docker/login-action@v3
47+
with:
48+
registry: ${{ env.REGISTRY }}
49+
username: ${{ github.actor }}
50+
password: ${{ secrets.GITHUB_TOKEN }}
51+
52+
- name: Extract Docker metadata
53+
id: meta
54+
uses: docker/metadata-action@v5
55+
with:
56+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-arm
57+
58+
- name: Build and push Docker image
59+
uses: docker/build-push-action@v5
60+
with:
61+
context: .
62+
file: apps/mcp-student/Dockerfile
63+
push: ${{ github.event_name != 'pull_request' }}
64+
tags: ${{ steps.meta.outputs.tags }}
65+
platforms: linux/arm64
66+
no-cache: true
67+
labels: ${{ steps.meta.outputs.labels }}
68+
69+
build-amd:
70+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
71+
runs-on: ubuntu-latest
72+
permissions:
73+
contents: read
74+
packages: write
75+
steps:
76+
- name: Checkout repository
77+
uses: actions/checkout@v4
78+
79+
- name: Set up QEMU
80+
uses: docker/setup-qemu-action@v3
81+
82+
- name: Set up Docker Buildx
83+
uses: docker/setup-buildx-action@v3
84+
85+
- name: Log into registry ${{ env.REGISTRY }}
86+
uses: docker/login-action@v3
87+
with:
88+
registry: ${{ env.REGISTRY }}
89+
username: ${{ github.actor }}
90+
password: ${{ secrets.GITHUB_TOKEN }}
91+
92+
- name: Extract Docker metadata
93+
id: meta
94+
uses: docker/metadata-action@v5
95+
with:
96+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-amd
97+
98+
- name: Build and push Docker image
99+
uses: docker/build-push-action@v5
100+
with:
101+
context: .
102+
file: apps/mcp-student/Dockerfile
103+
push: ${{ github.event_name != 'pull_request' }}
104+
tags: ${{ steps.meta.outputs.tags }}
105+
platforms: linux/amd64
106+
labels: ${{ steps.meta.outputs.labels }}

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,16 @@ Without Traefik, use `http://localhost:<port>` directly. The `*.klicker.com` dom
217217
- **Local embed harness target**: `util/embed-harness/` is for local verification only and should target the branch-local PWA (`http://127.0.0.1:3101/...`), not `https://pwa.klicker.com/...`, because production CSP / `frame-ancestors` blocks localhost embedding. (`util/embed-harness/`)
218218
- **Chat PWA login redirects**: `apps/chat/src/app/noLogin/page.tsx` must pass an absolute chat URL to the PWA login `redirect_to`; a relative chatbot path makes the PWA redirect to its own domain and 404. Local chat dev also needs ignored local env values for the backend `APP_SECRET` and `DATABASE_URL` so participant cookies verify and Prisma can load chatbot data.
219219
- **Chat Vitest alias resolution**: `apps/chat/vitest.config.ts` mirrors the app `@/*` alias from `apps/chat/tsconfig.json`; keep this in sync when adding client tests for modules that import from `@/src/...`.
220+
- **mcp-student build**: The TypeScript student MCP app uses plain `tsc -p tsconfig.build.json`; Rollup emitted `dist` but kept the process alive during initial setup, so keep this service unbundled unless there is a concrete reason to revisit. (`apps/mcp-student/`)
221+
- **mcp-student backend boundary**: `apps/mcp-student` must not use Prisma directly. Fetch practice pools through the participant-scoped GraphQL operation `studentMcpCoursePracticeQuiz`, which validates chatbot/course/enrollment and reuses `CourseService.getCoursePracticeQuiz` ordering. (`apps/mcp-student/src/graphqlClient.ts`, `packages/graphql/src/services/courses.ts`)
222+
- **mcp-student GraphQL transport**: Use `@apollo/client/core` with generated documents from `@klicker-uzh/graphql/dist/ops.js` and persisted hashes from `dist/client.json`; do not hard-code operation hashes or rely on non-persisted document POSTs for this service. (`apps/mcp-student/src/graphqlClient.ts`)
223+
- **Local dev Node TLS**: Node services must call local apps through the verified `https://*.klicker.com` Traefik domains. `_create_ssl_certificates.sh` copies the mkcert root to ignored `util/traefik/ssl/rootCA.pem`, and `_run_with_infisical.sh --env dev*` exports it as `NODE_EXTRA_CA_CERTS`. (`util/_create_ssl_certificates.sh`, `util/_run_with_infisical.sh`)
224+
- **Student practice MCP chat boundary**: `apps/chat` calls `apps/mcp-student` server-side via `MCP_STUDENT_URL`; only the answer-safe `start_student_practice_quiz` tool is exposed to the model, while answer submission stays behind the authenticated chat API route. (`apps/chat/src/services/studentPracticeMcp.ts`, `apps/chat/src/app/api/chatbots/[chatbotId]/practice/submit/route.ts`)
225+
- **Student practice history cards**: Signed MCP `questionRef` values expire, so chat history cards must be read-only after expiry. New quiz payloads include `expiresAt`; older payloads without it should be treated as archived. (`apps/chat/src/components/student-practice-quiz-card.tsx`, `apps/mcp-student/src/questionRef.ts`)
226+
- **mcp-student deployment boundary**: The Helm chart deploys `mcp-student` as an internal ClusterIP service and injects `MCP_STUDENT_URL` into chat. By default it reads `APP_SECRET` from the existing chat secret so participant MCP JWT validation and question refs use the same signing material. (`deploy/charts/klicker-uzh-v3/templates/deployment-mcp-student.yaml`, `deploy/charts/klicker-uzh-v3/templates/cm-chat.yaml`)
227+
- **Shared practice renderer in chat**: Do not wrap `StudentElement` in a chat-owned `<form>`; shared answer option buttons are regular buttons and can behave like implicit submit controls inside forms. Use an explicit chat submit button instead. (`apps/chat/src/components/student-practice-quiz-card.tsx`, `packages/shared-components/src/questions/`)
228+
- **Chat shared-component i18n**: Shared practice components use `next-intl` hooks, so `apps/chat` must wrap its App Router tree in `NextIntlClientProvider` before rendering them. (`apps/chat/src/app/layout.tsx`, `packages/shared-components/src/StudentElement.tsx`)
229+
- **Chat model reasoning effort changes**: Adding a reasoning effort requires updating chat validation, the GraphQL enum/schema, generated GraphQL artifacts, and the manage chatbot settings effort order; model-specific availability belongs in the chat and GraphQL model registries. (`apps/chat/src/lib/config/reasoning.ts`, `packages/graphql/src/schema/resource.ts`, `apps/frontend-manage/src/components/resources/chatbots/ChatbotDetails.tsx`)
220230

221231
## Factory Skills (AI Assistance)
222232

apps/chat/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
"@fortawesome/fontawesome-svg-core": "6.7.2",
1313
"@fortawesome/free-solid-svg-icons": "6.7.2",
1414
"@fortawesome/react-fontawesome": "0.2.2",
15+
"@klicker-uzh/i18n": "workspace:*",
1516
"@klicker-uzh/next-config": "workspace:*",
1617
"@klicker-uzh/prisma": "workspace:*",
1718
"@klicker-uzh/shared-components": "workspace:*",
19+
"@klicker-uzh/types": "workspace:*",
1820
"@langfuse/otel": "4.6.1",
1921
"@langfuse/tracing": "4.6.1",
2022
"@modelcontextprotocol/sdk": "1.17.5",
@@ -53,6 +55,8 @@
5355
},
5456
"devDependencies": {
5557
"@eslint/eslintrc": "~3",
58+
"@tailwindcss/aspect-ratio": "~0.4.2",
59+
"@tailwindcss/forms": "~0.5.10",
5660
"@tailwindcss/postcss": "~4.1.11",
5761
"@tailwindcss/typography": "~0.5.16",
5862
"@types/node": "^20.19.11",

apps/chat/src/app/api/chatbots/[chatbotId]/chat/route.ts

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
generateText,
1818
stepCountIs,
1919
streamText,
20+
tool,
2021
type ModelMessage,
2122
type StepResult,
2223
} from 'ai'
@@ -29,6 +30,13 @@ import {
2930
} from 'src/lib/config/reasoning'
3031
import { CreditsService } from 'src/services/credits'
3132
import { DisclaimersService } from 'src/services/disclaimers'
33+
import {
34+
formatPracticeCandidatesForPrompt,
35+
getPracticeStackForQuiz,
36+
lookupRelevantPracticeStacks,
37+
STUDENT_PRACTICE_QUIZ_TOOL_NAME,
38+
toPracticeCandidateId,
39+
} from 'src/services/studentPracticeMcp'
3240
import { ThreadService } from 'src/services/threads'
3341
import { z } from 'zod'
3442

@@ -631,7 +639,7 @@ export async function POST(
631639
if ('response' in authResult) {
632640
return authResult.response
633641
}
634-
const { participantId } = authResult
642+
const { participantId, chatbot: authChatbot } = authResult
635643

636644
// check disclaimer acceptance
637645
try {
@@ -845,13 +853,105 @@ export async function POST(
845853
}))
846854

847855
// Load MCP tools from database configurations or fallback to legacy
848-
const mcpTools = await getAggregatedMCPTools(mcpServersWithConfigs, chatbotId)
849-
const toolNames = Object.keys(mcpTools || {})
856+
const mcpTools = await getAggregatedMCPTools(
857+
mcpServersWithConfigs,
858+
chatbotId,
859+
participantId
860+
)
850861

851862
if (!chatbot) {
852863
return NextResponse.json({ error: 'Chatbot not found' }, { status: 404 })
853864
}
854865

866+
let practiceCandidatePrompt = ''
867+
let practiceCandidateCount = 0
868+
const practiceCandidateRefs = new Map<string, string>()
869+
870+
if (selectedMode === 'tutor') {
871+
try {
872+
const lookupResult = await lookupRelevantPracticeStacks({
873+
chatbotId,
874+
courseId: authChatbot.courseId,
875+
messages,
876+
participantId,
877+
})
878+
const candidates = lookupResult?.candidates ?? []
879+
practiceCandidateCount = candidates.length
880+
candidates.forEach((candidate, index) => {
881+
practiceCandidateRefs.set(
882+
toPracticeCandidateId(index),
883+
candidate.questionRef
884+
)
885+
})
886+
practiceCandidatePrompt = formatPracticeCandidatesForPrompt(candidates)
887+
888+
logChatDev('studentPractice.lookup', {
889+
requestId,
890+
chatbotId,
891+
participantId,
892+
candidateCount: practiceCandidateCount,
893+
})
894+
} catch (error) {
895+
console.warn(
896+
'Student practice lookup failed; continuing without quiz candidates',
897+
{
898+
requestId,
899+
chatbotId,
900+
error,
901+
}
902+
)
903+
}
904+
}
905+
906+
const studentPracticeTools: Record<string, any> = {}
907+
if (practiceCandidatePrompt) {
908+
studentPracticeTools[STUDENT_PRACTICE_QUIZ_TOOL_NAME] = tool({
909+
description:
910+
'Show a selected answer-safe practice quiz question to the student. Use only candidateId values from the current relevant practice candidate context.',
911+
inputSchema: z.object({
912+
candidateId: z
913+
.string()
914+
.min(1)
915+
.describe('Candidate id from the current practice candidate context'),
916+
}),
917+
execute: async ({ candidateId }) => {
918+
const questionRef = practiceCandidateRefs.get(candidateId)
919+
if (!questionRef) {
920+
throw new Error('Unknown practice candidate id')
921+
}
922+
923+
const payload = await getPracticeStackForQuiz({
924+
chatbotId,
925+
participantId,
926+
questionRef,
927+
})
928+
929+
if (!payload) {
930+
throw new Error('Student practice MCP is not configured')
931+
}
932+
933+
return {
934+
kind: 'student-practice-quiz',
935+
...payload,
936+
}
937+
},
938+
toModelOutput: () => ({
939+
type: 'text' as const,
940+
value:
941+
'A practice quiz was shown to the student. Wait for the student answer or submission result before giving feedback.',
942+
}),
943+
})
944+
}
945+
946+
const chatTools: Record<string, any> = {
947+
...(mcpTools || {}),
948+
...studentPracticeTools,
949+
}
950+
const toolNames = Object.keys(chatTools)
951+
const effectiveSystemPrompt = practiceCandidatePrompt
952+
? `${systemPrompt}\n\n${practiceCandidatePrompt}`
953+
: systemPrompt
954+
855955
const modelRegistry = getChatModelRegistry()
856956
const allowedIds =
857957
chatbot.allowedModelIds.length > 0
@@ -1034,6 +1134,7 @@ export async function POST(
10341134
maxOutputTokens: maxOutputTokens ?? null,
10351135
toolCount: toolNames.length,
10361136
toolNames,
1137+
practiceCandidateCount,
10371138
systemPromptLength: systemPrompt.length,
10381139
systemPromptHash: systemPrompt ? hashSnippet(systemPrompt) : null,
10391140
userPromptLengthTotal: userPrompt.length,
@@ -1246,10 +1347,10 @@ export async function POST(
12461347
},
12471348
},
12481349
messages: modelMessages as ModelMessage[],
1249-
tools: mcpTools,
1350+
tools: chatTools,
12501351
toolChoice: 'auto',
12511352
stopWhen: stepCountIs(5),
1252-
system: systemPrompt,
1353+
system: effectiveSystemPrompt,
12531354

12541355
abortSignal: req.signal,
12551356

0 commit comments

Comments
 (0)