Skip to content

Commit 5798140

Browse files
committed
refactor(mcp-student): simplify persisted graphql transport
1 parent 894efcc commit 5798140

4 files changed

Lines changed: 128 additions & 54 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ Without Traefik, use `http://localhost:<port>` directly. The `*.klicker.com` dom
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/...`.
220220
- **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/`)
221221
- **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`)
222223
- **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`)
223224
- **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`)
224225
- **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`)

apps/mcp-student/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
"license": "AGPL-3.0",
77
"main": "dist/index.js",
88
"dependencies": {
9+
"@apollo/client": "3.13.8",
910
"@klicker-uzh/graphql": "workspace:*",
1011
"@klicker-uzh/types": "workspace:*",
1112
"@klicker-uzh/util": "workspace:*",
1213
"fastmcp": "3.15.2",
14+
"graphql": "16.11.0",
1315
"zod": "3.25.76"
1416
},
1517
"devDependencies": {

apps/mcp-student/src/graphqlClient.ts

Lines changed: 119 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
1+
import {
2+
ApolloClient,
3+
HttpLink,
4+
InMemoryCache,
5+
from,
6+
type NormalizedCacheObject,
7+
type OperationVariables,
8+
type TypedDocumentNode,
9+
} from '@apollo/client/core'
10+
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries'
111
import hashes from '@klicker-uzh/graphql/dist/client.json'
2-
import type {
3-
GetCoursePracticeQuizWithoutSolutionsQuery,
4-
GetCoursePracticeQuizWithoutSolutionsQueryVariables,
5-
RespondToElementStackMutation,
6-
RespondToElementStackMutationVariables,
12+
import {
13+
GetCoursePracticeQuizWithoutSolutionsDocument,
14+
RespondToElementStackDocument,
15+
type GetCoursePracticeQuizWithoutSolutionsQuery,
16+
type GetCoursePracticeQuizWithoutSolutionsQueryVariables,
17+
type RespondToElementStackMutation,
18+
type RespondToElementStackMutationVariables,
719
} from '@klicker-uzh/graphql/dist/ops.js'
820
import type {
921
StudentMcpPracticeQuiz as PracticeQuiz,
1022
StudentMcpStackResponseInput as StackResponseInput,
1123
} from '@klicker-uzh/types'
24+
import type { DocumentNode, OperationDefinitionNode } from 'graphql'
1225

1326
const PERSISTED_OPERATION_HASHES = hashes as Record<string, string>
1427

15-
type GraphQLResponse<T> = {
16-
data?: T
17-
errors?: Array<{ message?: string }>
18-
}
19-
2028
export type SubmitStackAnswerInput = {
2129
courseId: string
2230
responses: StackResponseInput[]
@@ -32,70 +40,127 @@ function operationHash(operationName: string): string {
3240
return hash
3341
}
3442

43+
function documentOperationName(document: DocumentNode): string {
44+
const definition = document.definitions.find(
45+
(value): value is OperationDefinitionNode =>
46+
value.kind === 'OperationDefinition'
47+
)
48+
49+
if (!definition?.name?.value) {
50+
throw new Error('GraphQL document is missing an operation name')
51+
}
52+
53+
return definition.name.value
54+
}
55+
3556
export class PersistedGraphQLClient {
36-
constructor(
37-
private readonly endpoint: string,
38-
private readonly fetchImpl: typeof fetch = fetch
39-
) {}
57+
private readonly client: ApolloClient<NormalizedCacheObject>
4058

41-
async execute<TData, TVariables extends Record<string, unknown>>(
42-
operationName: string,
43-
variables: TVariables,
44-
bearerToken: string
45-
): Promise<TData> {
46-
const response = await this.fetchImpl(this.endpoint, {
47-
body: JSON.stringify({
48-
operationName,
49-
variables,
50-
extensions: {
51-
persistedQuery: {
52-
version: 1,
53-
sha256Hash: operationHash(operationName),
59+
constructor(endpoint: string, fetchImpl: typeof fetch = fetch) {
60+
this.client = new ApolloClient({
61+
cache: new InMemoryCache(),
62+
link: from([
63+
createPersistedQueryLink({
64+
generateHash: (document) =>
65+
operationHash(documentOperationName(document)),
66+
retry: () => false,
67+
}),
68+
new HttpLink({
69+
fetch: fetchImpl,
70+
headers: {
71+
Accept: 'application/json',
72+
'x-graphql-yoga-csrf': 'true',
5473
},
55-
},
56-
}),
74+
uri: endpoint,
75+
}),
76+
]),
77+
})
78+
}
79+
80+
private authContext(bearerToken: string) {
81+
return {
5782
headers: {
58-
Accept: 'application/json',
5983
Authorization: `Bearer ${bearerToken}`,
60-
'Content-Type': 'application/json',
61-
'x-graphql-yoga-csrf': 'true',
6284
},
63-
method: 'POST',
64-
})
65-
66-
if (!response.ok) {
67-
const body = await response.text().catch(() => '')
68-
const details = body ? `: ${body.slice(0, 500)}` : ''
69-
throw new Error(
70-
`GraphQL ${operationName} failed with HTTP ${response.status}${details}`
71-
)
7285
}
86+
}
7387

74-
const payload = (await response.json()) as GraphQLResponse<TData>
75-
if (payload.errors?.length) {
76-
throw new Error(
77-
payload.errors
78-
.map((error) => error.message ?? 'Unknown GraphQL error')
79-
.join('; ')
80-
)
88+
private async query<
89+
TData,
90+
TVariables extends OperationVariables = OperationVariables,
91+
>(
92+
query: TypedDocumentNode<TData, TVariables>,
93+
variables: TVariables,
94+
bearerToken: string
95+
): Promise<TData> {
96+
const operationName = documentOperationName(query)
97+
const result = await this.withOperationError(
98+
operationName,
99+
this.client.query<TData, TVariables>({
100+
context: this.authContext(bearerToken),
101+
fetchPolicy: 'no-cache',
102+
query,
103+
variables,
104+
})
105+
)
106+
107+
return this.requireData(operationName, result)
108+
}
109+
110+
private async mutate<
111+
TData,
112+
TVariables extends OperationVariables = OperationVariables,
113+
>(
114+
mutation: TypedDocumentNode<TData, TVariables>,
115+
variables: TVariables,
116+
bearerToken: string
117+
): Promise<TData> {
118+
const operationName = documentOperationName(mutation)
119+
const result = await this.withOperationError(
120+
operationName,
121+
this.client.mutate<TData, TVariables>({
122+
context: this.authContext(bearerToken),
123+
fetchPolicy: 'no-cache',
124+
mutation,
125+
variables,
126+
})
127+
)
128+
129+
return this.requireData(operationName, result)
130+
}
131+
132+
private async withOperationError<T>(
133+
operationName: string,
134+
operation: Promise<T>
135+
): Promise<T> {
136+
try {
137+
return await operation
138+
} catch (error) {
139+
const message = error instanceof Error ? error.message : String(error)
140+
throw new Error(`GraphQL ${operationName} failed: ${message}`)
81141
}
142+
}
82143

83-
if (!payload.data) {
144+
private requireData<TData>(
145+
operationName: string,
146+
result: { data?: TData | null }
147+
): TData {
148+
if (!result.data) {
84149
throw new Error(`GraphQL ${operationName} returned no data`)
85150
}
86151

87-
return payload.data
152+
return result.data
88153
}
89154

90155
async getCoursePracticeQuiz(
91156
input: { chatbotId: string; courseId: string },
92157
bearerToken: string
93158
): Promise<PracticeQuiz | null> {
94-
const data = await this.execute<
159+
const data = await this.query<
95160
GetCoursePracticeQuizWithoutSolutionsQuery,
96161
GetCoursePracticeQuizWithoutSolutionsQueryVariables
97162
>(
98-
'GetCoursePracticeQuizWithoutSolutions',
163+
GetCoursePracticeQuizWithoutSolutionsDocument,
99164
{
100165
chatbotId: input.chatbotId,
101166
courseId: input.courseId,
@@ -113,11 +178,11 @@ export class PersistedGraphQLClient {
113178
input: SubmitStackAnswerInput,
114179
bearerToken: string
115180
): Promise<unknown> {
116-
const data = await this.execute<
181+
const data = await this.mutate<
117182
RespondToElementStackMutation,
118183
RespondToElementStackMutationVariables
119184
>(
120-
'RespondToElementStack',
185+
RespondToElementStackDocument,
121186
{
122187
courseId: input.courseId,
123188
isOwner: false,

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)