moodlia-sync-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ This project is licensed under the GNU General Public License, version 3 or later.
5
+ See https://www.gnu.org/licenses/gpl-3.0.txt for the complete license text.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # MoodlIA Sync MCP
2
+
3
+ `moodlia-sync-mcp` is an MCP coordinator for one-way course-content synchronization between Moodle sites. Each endpoint may use Moodle Core web services, the MoodlIA plugin, or both. Provider selection is capability-based and is frozen into every approved action.
4
+
5
+ It requires Node.js 22.13 or later because its durable state store uses the
6
+ built-in SQLite API without experimental process flags.
7
+
8
+ The coordinator does not send Moodle tokens or binary files through MCP tool arguments or results. Profiles reference environment variables, and an allowlist restricts profile pairs, course IDs, and effects.
9
+
10
+ ## Status
11
+
12
+ The current preview synchronizes verified course metadata, hidden target-course creation, sections, groups and grouping membership, portable Pages/Labels/URLs, file resources and folders, Books with chapter files, selected assignment definitions and grading forms, Workshop forms, supported question banks and Quiz slots, portable Lesson pages, Database fields, Feedback items, course-completion criteria, and selected gradebook configuration. The coordinator uses the same engine and adaptive adapters as the CLI, so provider selection and advanced action semantics are identical. Existing unsupported authoring changes and unsafe transformations are reported before writing. It does not use Moodle backup files.
13
+
14
+ ## Configuration
15
+
16
+ Create `.moodle-profiles.json`:
17
+
18
+ ```json
19
+ {
20
+ "schema_version": 1,
21
+ "profiles": {
22
+ "source": {
23
+ "url": "https://source.example.edu",
24
+ "backend": "auto",
25
+ "credentials": {
26
+ "core": { "token_env": "SOURCE_CORE_TOKEN" },
27
+ "moodlia": { "token_env": "SOURCE_MOODLIA_TOKEN" }
28
+ }
29
+ },
30
+ "target": {
31
+ "url": "https://target.example.edu",
32
+ "backend": "auto",
33
+ "credentials": {
34
+ "core": { "token_env": "TARGET_CORE_TOKEN" },
35
+ "moodlia": { "token_env": "TARGET_MOODLIA_TOKEN" }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ Create `.moodle-sync-policy.json`:
43
+
44
+ ```json
45
+ {
46
+ "schema_version": 1,
47
+ "allowed_profiles": ["source", "target"],
48
+ "allowed_pairs": [
49
+ {
50
+ "source": "source",
51
+ "target": "target",
52
+ "source_courses": [42],
53
+ "target_courses": [81],
54
+ "target_categories": [7],
55
+ "effects": ["content.read", "content.write"]
56
+ }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ Set `MOODLIA_SYNC_CONFIG`, `MOODLIA_SYNC_POLICY`, and optionally `MOODLIA_SYNC_STATE`. Start the stdio server with `npx moodlia-sync-mcp`.
62
+
63
+ For Streamable HTTP, set a random `MOODLIA_SYNC_BEARER_TOKEN` of at least 32 bytes and run `npx moodlia-sync-mcp-http`. It binds to `127.0.0.1:3333` by default. Put a TLS-authenticated reverse proxy in front of it for remote clients, set `MOODLIA_SYNC_ALLOWED_HOSTS`, and never expose the plain HTTP listener directly. The bearer token authenticates the client to the coordinator; Moodle tokens remain separate downstream credentials.
64
+
65
+ ## Approval boundary
66
+
67
+ `sync_plan_course` is read-only. `sync_apply_plan` accepts only a plan that was approved outside MCP and stored in the same SQLite state database:
68
+
69
+ ```powershell
70
+ moodlia course sync `
71
+ --approve-plan ".moodle-sync\plans\PLAN_ID.json" `
72
+ --state ".moodle-sync\coordinator.sqlite" `
73
+ --yes
74
+ ```
75
+
76
+ Approval is bound to the complete plan digest, expires with the plan, and is consumed before execution. A model-provided boolean is never treated as human authorization.
77
+
78
+ ## MCP tools
79
+
80
+ - `sync_list_profiles`
81
+ - `sync_discover_capabilities`
82
+ - `sync_plan_course`
83
+ - `sync_get_plan` (bounded pagination across actions, conflicts, gaps, unchanged, and unknown entries)
84
+ - `sync_apply_plan`
85
+ - `sync_get_job`
86
+ - `sync_cancel_job`
87
+ - `sync_resume_job`
88
+ - `sync_verify_course`
89
+ - `sync_get_conflicts`
90
+ - `sync_resolve_conflict`
91
+ - `sync_get_history`
92
+
93
+ Jobs, action attempts, approvals, mappings, baselines, and leases are durable in SQLite. On restart, in-flight jobs become `interrupted` and must be reconciled and explicitly re-approved before resume. Timeout outcomes are marked `unknown_outcome`, not retried blindly.
94
+
95
+ All documentation, schemas, source identifiers, and source comments are in English.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "moodlia-sync-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP coordinator for policy-bound cross-site Moodle course synchronization.",
5
+ "type": "module",
6
+ "license": "GPL-3.0-or-later",
7
+ "author": "Pablo Gallego",
8
+ "bin": {
9
+ "moodlia-sync-mcp": "server/stdio.mjs",
10
+ "moodlia-sync-mcp-http": "server/http.mjs"
11
+ },
12
+ "exports": {
13
+ ".": "./server/coordinator.mjs",
14
+ "./mcp-server": "./server/mcp-server.mjs",
15
+ "./http": "./server/http.mjs"
16
+ },
17
+ "files": [
18
+ "server/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --test tests/*.test.mjs",
24
+ "check": "node --check server/coordinator.mjs && node --check server/mcp-server.mjs && node --check server/stdio.mjs && node --check server/http.mjs && npm test",
25
+ "pack:check": "npm pack --dry-run"
26
+ },
27
+ "engines": {
28
+ "node": ">=22.13"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.30.0",
32
+ "moodle-core-cli": "^0.3.0",
33
+ "moodlia": "^0.3.0",
34
+ "zod": "^4.6.5"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org"
39
+ }
40
+ }
@@ -0,0 +1,369 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createAdaptiveSiteAdapter } from 'moodlia/adaptive';
6
+ import { loadContractFromFile } from 'moodlia';
7
+ import { describeProfile, loadProfiles, resolveProfile } from 'moodle-core-cli/profiles';
8
+ import { createCourseSyncEngine, SqliteSyncStateStore, validateSyncPlan } from 'moodle-core-cli/sync';
9
+
10
+ function assertObject(value, name) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object.`);
12
+ return value;
13
+ }
14
+
15
+ function loadJson(filePath, name) {
16
+ return assertObject(JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8').replace(/^\uFEFF/, '')), name);
17
+ }
18
+
19
+ function defaultContractPath() {
20
+ return fileURLToPath(import.meta.resolve('moodlia/contract'));
21
+ }
22
+
23
+ export function loadCoordinatorPolicy(policyPath) {
24
+ const policy = loadJson(policyPath, 'coordinator policy');
25
+ if (policy.schema_version !== 1 || !Array.isArray(policy.allowed_profiles) || !Array.isArray(policy.allowed_pairs)) {
26
+ throw new TypeError('Coordinator policy must use schema version 1 and define allowed_profiles and allowed_pairs.');
27
+ }
28
+ return policy;
29
+ }
30
+
31
+ export class SyncCoordinator {
32
+ constructor({ configPath, policyPath, statePath, contractPath = defaultContractPath(), environment = process.env }) {
33
+ this.profiles = loadProfiles(configPath);
34
+ this.policy = loadCoordinatorPolicy(policyPath);
35
+ this.environment = environment;
36
+ this.contract = loadContractFromFile(contractPath);
37
+ fs.mkdirSync(path.dirname(path.resolve(statePath)), { recursive: true, mode: 0o700 });
38
+ this.store = new SqliteSyncStateStore(statePath);
39
+ this.engine = createCourseSyncEngine({ stateStore: this.store });
40
+ this.workers = new Map();
41
+ for (const job of this.store.listJobs()) {
42
+ if (!['queued', 'running', 'cancel_requested'].includes(job.status)) continue;
43
+ job.status = 'interrupted';
44
+ job.updated_at = new Date().toISOString();
45
+ job.error = { code: 'coordinator_restarted', message: 'The coordinator stopped before this job completed.' };
46
+ this.store.saveJob(job);
47
+ }
48
+ }
49
+
50
+ assertProfileAllowed(name) {
51
+ if (!this.policy.allowed_profiles.includes(name)) throw new TypeError(`Profile ${name} is not authorized for this coordinator.`);
52
+ }
53
+
54
+ pairPolicy(sourceProfile, targetProfile, sourceCourseId, targetCourseId, effect, targetCategoryId = null) {
55
+ this.assertProfileAllowed(sourceProfile);
56
+ this.assertProfileAllowed(targetProfile);
57
+ const pair = this.policy.allowed_pairs.find((entry) =>
58
+ entry.source === sourceProfile
59
+ && entry.target === targetProfile
60
+ && (entry.source_courses === '*' || entry.source_courses?.includes(sourceCourseId))
61
+ && (targetCourseId === null
62
+ ? (entry.target_categories === '*' || entry.target_categories?.includes(targetCategoryId))
63
+ : (entry.target_courses === '*' || entry.target_courses?.includes(targetCourseId))));
64
+ if (!pair || !pair.effects?.includes(effect)) {
65
+ throw new TypeError('The requested profile pair, course IDs, or effect is not authorized.');
66
+ }
67
+ return pair;
68
+ }
69
+
70
+ profile(name) {
71
+ this.assertProfileAllowed(name);
72
+ return resolveProfile(this.profiles, name, this.environment);
73
+ }
74
+
75
+ adapter(name, allowWrite = false) {
76
+ return createAdaptiveSiteAdapter({
77
+ profile: this.profile(name),
78
+ moodliaContract: this.contract,
79
+ allowWrite
80
+ });
81
+ }
82
+
83
+ listProfiles() {
84
+ return this.policy.allowed_profiles.map((name) => describeProfile(this.profiles.get(name)));
85
+ }
86
+
87
+ async discoverCapabilities({ profile, course_id }) {
88
+ const adapter = this.adapter(profile);
89
+ return {
90
+ profile: describeProfile(this.profiles.get(profile)),
91
+ discovery: await adapter.discoverSite(),
92
+ capabilities: await adapter.syncCapabilities({ courseId: course_id })
93
+ };
94
+ }
95
+
96
+ async planCourse({
97
+ source_profile, source_course_id, target_profile, target_course_id,
98
+ create_target_category_id, target_shortname, unsupported_policy, conflict_policy
99
+ }) {
100
+ const createsTarget = create_target_category_id !== undefined;
101
+ if ((target_course_id !== undefined) === createsTarget) {
102
+ throw new TypeError('Provide exactly one target_course_id or create_target_category_id.');
103
+ }
104
+ if (createsTarget && !String(target_shortname ?? '').trim()) {
105
+ throw new TypeError('target_shortname is required when creating a target course.');
106
+ }
107
+ const targetCourseId = target_course_id ?? null;
108
+ this.pairPolicy(
109
+ source_profile, target_profile, source_course_id, targetCourseId, 'content.read',
110
+ create_target_category_id ?? null
111
+ );
112
+ return this.engine.plan({
113
+ sourceAdapter: this.adapter(source_profile),
114
+ targetAdapter: this.adapter(target_profile),
115
+ sourceCourseId: source_course_id,
116
+ targetCourseId,
117
+ targetCreation: createsTarget ? {
118
+ category_id: create_target_category_id,
119
+ shortname: String(target_shortname)
120
+ } : null,
121
+ policies: { unsupported: unsupported_policy ?? 'error', conflict: conflict_policy ?? 'abort' }
122
+ });
123
+ }
124
+
125
+ authorizePlan(plan_id, plan_digest) {
126
+ const plan = this.store.getPlan(plan_id);
127
+ if (!plan) throw new TypeError(`Unknown sync plan: ${plan_id}.`);
128
+ validateSyncPlan(plan);
129
+ this.pairPolicy(
130
+ plan.source.site.profile,
131
+ plan.target.site.profile,
132
+ plan.source.course_id,
133
+ plan.target.course_id,
134
+ 'content.write',
135
+ plan.target.creation?.category_id ?? null
136
+ );
137
+ return plan;
138
+ }
139
+
140
+ claimApprovedJob(plan, planDigest, job) {
141
+ if (!this.store.consumeApprovalAndSaveJob(plan.plan_id, planDigest, job)) {
142
+ throw new TypeError('This exact plan has no unconsumed external approval or it has expired.');
143
+ }
144
+ return job;
145
+ }
146
+
147
+ trackWorker(jobId, promise) {
148
+ this.workers.set(jobId, promise);
149
+ promise.finally(() => this.workers.delete(jobId)).catch(() => {});
150
+ return promise;
151
+ }
152
+
153
+ async applyPlan({ plan_id, plan_digest }) {
154
+ const plan = this.authorizePlan(plan_id, plan_digest);
155
+ const job = this.claimApprovedJob(plan, plan_digest, {
156
+ schema_version: 1,
157
+ job_id: randomUUID(),
158
+ plan_id,
159
+ status: 'queued',
160
+ created_at: new Date().toISOString(),
161
+ updated_at: new Date().toISOString(),
162
+ results: []
163
+ });
164
+ return this.engine.apply({
165
+ planId: plan_id,
166
+ planDigest: plan_digest,
167
+ sourceAdapter: this.adapter(plan.source.site.profile),
168
+ targetAdapter: this.adapter(plan.target.site.profile, true),
169
+ jobId: job.job_id
170
+ });
171
+ }
172
+
173
+ startPlan({ plan_id, plan_digest }) {
174
+ const plan = this.authorizePlan(plan_id, plan_digest);
175
+ const job = {
176
+ schema_version: 1,
177
+ job_id: randomUUID(),
178
+ plan_id,
179
+ status: 'queued',
180
+ created_at: new Date().toISOString(),
181
+ updated_at: new Date().toISOString(),
182
+ results: []
183
+ };
184
+ this.claimApprovedJob(plan, plan_digest, job);
185
+ setImmediate(() => {
186
+ const worker = this.engine.apply({
187
+ planId: plan_id,
188
+ planDigest: plan_digest,
189
+ sourceAdapter: this.adapter(plan.source.site.profile),
190
+ targetAdapter: this.adapter(plan.target.site.profile, true),
191
+ jobId: job.job_id
192
+ }).catch((error) => {
193
+ const current = this.store.getJob(job.job_id) ?? job;
194
+ if (['failed', 'partially_applied', 'verification_failed', 'unknown_outcome', 'cancelled'].includes(current.status)) return;
195
+ current.status = 'failed';
196
+ current.error = { name: error.name, code: error.code, message: error.message };
197
+ current.updated_at = new Date().toISOString();
198
+ this.store.saveJob(current);
199
+ });
200
+ this.trackWorker(job.job_id, worker);
201
+ });
202
+ return job;
203
+ }
204
+
205
+ getJob(jobId) {
206
+ const job = this.store.getJob(jobId);
207
+ if (!job) throw new TypeError(`Unknown sync job: ${jobId}.`);
208
+ return job;
209
+ }
210
+
211
+ requestCancellation(jobId) {
212
+ const job = this.getJob(jobId);
213
+ if (!['queued', 'running'].includes(job.status)) return job;
214
+ job.status = 'cancel_requested';
215
+ job.updated_at = new Date().toISOString();
216
+ this.store.saveJob(job);
217
+ return job;
218
+ }
219
+
220
+ resumeJob({ job_id, plan_digest }) {
221
+ const existingJob = this.getJob(job_id);
222
+ if (!['failed', 'partially_applied', 'verification_failed', 'unknown_outcome', 'cancelled', 'interrupted'].includes(existingJob.status)) {
223
+ throw new TypeError('Only an interrupted or failed job can be resumed.');
224
+ }
225
+ const plan = this.authorizePlan(existingJob.plan_id, plan_digest);
226
+ existingJob.status = 'queued';
227
+ existingJob.updated_at = new Date().toISOString();
228
+ this.claimApprovedJob(plan, plan_digest, existingJob);
229
+ setImmediate(() => {
230
+ const worker = this.engine.apply({
231
+ planId: plan.plan_id,
232
+ planDigest: plan_digest,
233
+ sourceAdapter: this.adapter(plan.source.site.profile),
234
+ targetAdapter: this.adapter(plan.target.site.profile, true),
235
+ resumeJobId: job_id
236
+ }).catch((error) => {
237
+ const current = this.store.getJob(job_id) ?? existingJob;
238
+ if (['failed', 'partially_applied', 'verification_failed', 'unknown_outcome', 'cancelled'].includes(current.status)) return;
239
+ current.status = current.results?.length > 0 ? 'partially_applied' : 'failed';
240
+ current.error = { name: error.name, code: error.code, message: error.message };
241
+ current.updated_at = new Date().toISOString();
242
+ this.store.saveJob(current);
243
+ });
244
+ this.trackWorker(job_id, worker);
245
+ });
246
+ return existingJob;
247
+ }
248
+
249
+ listHistory() {
250
+ return this.store.listJobs();
251
+ }
252
+
253
+ getPlan({ plan_id: planId, section = 'actions', cursor = 0, limit = 50 }) {
254
+ const plan = this.store.getPlan(planId);
255
+ if (!plan) throw new TypeError(`Unknown sync plan: ${planId}.`);
256
+ this.pairPolicy(
257
+ plan.source.site.profile,
258
+ plan.target.site.profile,
259
+ plan.source.course_id,
260
+ plan.target.course_id,
261
+ 'content.read',
262
+ plan.target.creation?.category_id ?? null
263
+ );
264
+ const collections = {
265
+ actions: plan.actions ?? [],
266
+ conflicts: plan.conflicts ?? [],
267
+ divergences: plan.divergences ?? [],
268
+ unsupported: plan.unsupported ?? [],
269
+ skipped: plan.skipped ?? [],
270
+ unchanged: plan.unchanged ?? [],
271
+ unknown: plan.unknown ?? []
272
+ };
273
+ if (!Object.hasOwn(collections, section)) throw new TypeError(`Unknown plan section: ${section}.`);
274
+ if (!Number.isInteger(cursor) || cursor < 0) throw new TypeError('cursor must be a non-negative integer.');
275
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
276
+ throw new TypeError('limit must be an integer from 1 to 100.');
277
+ }
278
+ const collection = collections[section];
279
+ const items = collection.slice(cursor, cursor + limit);
280
+ const nextCursor = cursor + items.length < collection.length ? cursor + items.length : null;
281
+ return {
282
+ plan_id: plan.plan_id,
283
+ digest: plan.digest,
284
+ schema_version: plan.schema_version,
285
+ created_at: plan.created_at,
286
+ expires_at: plan.expires_at,
287
+ applicable: plan.applicable,
288
+ action_summary: plan.action_summary,
289
+ policies: plan.policies,
290
+ section,
291
+ cursor,
292
+ limit,
293
+ total: collection.length,
294
+ next_cursor: nextCursor,
295
+ items
296
+ };
297
+ }
298
+
299
+ getConflicts(planId) {
300
+ const plan = this.store.getPlan(planId);
301
+ if (!plan) throw new TypeError(`Unknown sync plan: ${planId}.`);
302
+ this.pairPolicy(
303
+ plan.source.site.profile,
304
+ plan.target.site.profile,
305
+ plan.source.course_id,
306
+ plan.target.course_id,
307
+ 'content.read',
308
+ plan.target.creation?.category_id ?? null
309
+ );
310
+ return { plan_id: planId, conflicts: plan.conflicts ?? [], divergences: plan.divergences ?? [] };
311
+ }
312
+
313
+ async resolveConflicts({ plan_id: planId, resolution }) {
314
+ if (!['source-wins', 'target-wins'].includes(resolution)) {
315
+ throw new TypeError('resolution must be source-wins or target-wins.');
316
+ }
317
+ const plan = this.store.getPlan(planId);
318
+ if (!plan) throw new TypeError(`Unknown sync plan: ${planId}.`);
319
+ const binding = this.store.getBinding(plan.binding_id);
320
+ const targetCourseId = binding?.target?.course_id ?? plan.target.course_id;
321
+ this.pairPolicy(
322
+ plan.source.site.profile,
323
+ plan.target.site.profile,
324
+ plan.source.course_id,
325
+ targetCourseId,
326
+ 'content.read',
327
+ plan.target.creation?.category_id ?? null
328
+ );
329
+ return this.engine.plan({
330
+ sourceAdapter: this.adapter(plan.source.site.profile),
331
+ targetAdapter: this.adapter(plan.target.site.profile),
332
+ sourceCourseId: plan.source.course_id,
333
+ targetCourseId,
334
+ targetCreation: targetCourseId ? null : plan.target.creation,
335
+ mapping: plan.entity_mapping_snapshot ?? {},
336
+ policies: { unsupported: plan.policies.unsupported, conflict: resolution }
337
+ });
338
+ }
339
+
340
+ async verifyCourse({ plan_id: planId, job_id: jobId }) {
341
+ const plan = this.store.getPlan(planId);
342
+ if (!plan) throw new TypeError(`Unknown sync plan: ${planId}.`);
343
+ const binding = this.store.getBinding(plan.binding_id);
344
+ const targetCourseId = binding?.target?.course_id ?? plan.target.course_id;
345
+ this.pairPolicy(
346
+ plan.source.site.profile,
347
+ plan.target.site.profile,
348
+ plan.source.course_id,
349
+ targetCourseId,
350
+ 'content.read',
351
+ plan.target.creation?.category_id ?? null
352
+ );
353
+ return this.engine.verify({
354
+ planId,
355
+ jobId,
356
+ targetAdapter: this.adapter(plan.target.site.profile)
357
+ });
358
+ }
359
+
360
+ async close() {
361
+ for (const jobId of this.workers.keys()) this.requestCancellation(jobId);
362
+ await Promise.allSettled([...this.workers.values()]);
363
+ this.store.close();
364
+ }
365
+ }
366
+
367
+ export function createSyncCoordinator(options) {
368
+ return new SyncCoordinator(options);
369
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
6
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
7
+ import { createSyncCoordinator } from './coordinator.mjs';
8
+ import { createMcpServer } from './mcp-server.mjs';
9
+
10
+ function requiredEnvironment(environment, name) {
11
+ const value = environment[name];
12
+ if (!value) throw new TypeError(`${name} is required.`);
13
+ return value;
14
+ }
15
+
16
+ function printHelp() {
17
+ process.stdout.write(`Usage: moodlia-sync-mcp-http\n\nEnvironment:\n MOODLIA_SYNC_CONFIG Profile configuration JSON path (required)\n MOODLIA_SYNC_POLICY Coordinator policy JSON path (required)\n MOODLIA_SYNC_BEARER_TOKEN MCP bearer token of at least 32 bytes (required)\n MOODLIA_SYNC_STATE SQLite state path\n MOODLIA_SYNC_HOST Listen host (default: 127.0.0.1)\n MOODLIA_SYNC_PORT Listen port (default: 3333)\n MOODLIA_SYNC_ALLOWED_HOSTS Comma-separated Host allowlist\n`);
18
+ }
19
+
20
+ export function tokenMatches(header, expected) {
21
+ const supplied = Buffer.from(String(header ?? '').replace(/^Bearer\s+/i, ''));
22
+ const wanted = Buffer.from(expected);
23
+ return supplied.length === wanted.length && timingSafeEqual(supplied, wanted);
24
+ }
25
+
26
+ export function createAuthenticatedMcpApp({ coordinator, bearerToken, host = '127.0.0.1', allowedHosts = [] }) {
27
+ if (!coordinator) throw new TypeError('coordinator is required.');
28
+ if (Buffer.byteLength(String(bearerToken ?? '')) < 32) {
29
+ throw new TypeError('bearerToken must contain at least 32 bytes.');
30
+ }
31
+ const app = createMcpExpressApp({ host, ...(allowedHosts.length > 0 ? { allowedHosts } : {}) });
32
+ const sessions = new Map();
33
+ app.get('/health', (_request, response) => response.json({ status: 'ok' }));
34
+ app.use('/mcp', (request, response, next) => {
35
+ if (!tokenMatches(request.headers.authorization, bearerToken)) {
36
+ response.status(401).set('WWW-Authenticate', 'Bearer').json({ error: 'unauthorized' });
37
+ return;
38
+ }
39
+ next();
40
+ });
41
+ app.post('/mcp', async (request, response) => {
42
+ try {
43
+ const sessionId = request.headers['mcp-session-id'];
44
+ let session = sessionId ? sessions.get(String(sessionId)) : null;
45
+ if (!session && !sessionId && isInitializeRequest(request.body)) {
46
+ const server = createMcpServer(coordinator);
47
+ const transport = new StreamableHTTPServerTransport({
48
+ sessionIdGenerator: () => randomUUID(),
49
+ enableJsonResponse: true,
50
+ onsessioninitialized: (id) => sessions.set(id, { server, transport })
51
+ });
52
+ transport.onclose = () => {
53
+ if (transport.sessionId) sessions.delete(transport.sessionId);
54
+ };
55
+ await server.connect(transport);
56
+ session = { server, transport };
57
+ }
58
+ if (!session) {
59
+ response.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Invalid MCP session.' }, id: null });
60
+ return;
61
+ }
62
+ await session.transport.handleRequest(request, response, request.body);
63
+ } catch {
64
+ if (!response.headersSent) {
65
+ response.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error.' }, id: null });
66
+ }
67
+ }
68
+ });
69
+ for (const method of ['get', 'delete']) {
70
+ app[method]('/mcp', async (request, response) => {
71
+ const session = sessions.get(String(request.headers['mcp-session-id'] ?? ''));
72
+ if (!session) {
73
+ response.status(400).send('Invalid MCP session.');
74
+ return;
75
+ }
76
+ await session.transport.handleRequest(request, response);
77
+ });
78
+ }
79
+ return { app, sessions };
80
+ }
81
+
82
+ export function startHttpServer(environment = process.env) {
83
+ const host = environment.MOODLIA_SYNC_HOST ?? '127.0.0.1';
84
+ const port = Number(environment.MOODLIA_SYNC_PORT ?? 3333);
85
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new TypeError('MOODLIA_SYNC_PORT is invalid.');
86
+ if (!['127.0.0.1', '::1', 'localhost'].includes(host)
87
+ && environment.MOODLIA_SYNC_ALLOW_INSECURE_HTTP !== 'true') {
88
+ throw new TypeError('Non-loopback HTTP requires explicit opt-in and a TLS reverse proxy.');
89
+ }
90
+ const bearerToken = requiredEnvironment(environment, 'MOODLIA_SYNC_BEARER_TOKEN');
91
+ const coordinator = createSyncCoordinator({
92
+ configPath: requiredEnvironment(environment, 'MOODLIA_SYNC_CONFIG'),
93
+ policyPath: requiredEnvironment(environment, 'MOODLIA_SYNC_POLICY'),
94
+ statePath: environment.MOODLIA_SYNC_STATE ?? '.moodle-sync/coordinator.sqlite',
95
+ environment
96
+ });
97
+ const allowedHosts = (environment.MOODLIA_SYNC_ALLOWED_HOSTS ?? '')
98
+ .split(',').map((value) => value.trim()).filter(Boolean);
99
+ const { app, sessions } = createAuthenticatedMcpApp({ coordinator, bearerToken, host, allowedHosts });
100
+ const httpServer = app.listen(port, host);
101
+ return { coordinator, httpServer, sessions };
102
+ }
103
+
104
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
105
+ if (process.argv.slice(2).some((argument) => argument === '--help' || argument === '-h')) {
106
+ printHelp();
107
+ process.exit(0);
108
+ }
109
+ const runtime = startHttpServer();
110
+ for (const signal of ['SIGINT', 'SIGTERM']) {
111
+ process.once(signal, async () => {
112
+ runtime.httpServer.close();
113
+ await Promise.allSettled([...runtime.sessions.values()].map(({ server }) => server.close()));
114
+ await runtime.coordinator.close();
115
+ process.exit(0);
116
+ });
117
+ }
118
+ }
@@ -0,0 +1,73 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+
4
+ function jsonResult(value) {
5
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: value };
6
+ }
7
+
8
+ export function createMcpServer(coordinator) {
9
+ const server = new McpServer({ name: 'moodlia-sync', version: '0.1.0' });
10
+
11
+ server.registerTool('sync_list_profiles', {
12
+ description: 'List authorized Moodle profiles without returning credentials.', inputSchema: {}
13
+ }, async () => jsonResult({ profiles: coordinator.listProfiles() }));
14
+ server.registerTool('sync_discover_capabilities', {
15
+ description: 'Discover live synchronization capabilities for an authorized profile.',
16
+ inputSchema: { profile: z.string().min(1), course_id: z.number().int().positive().optional() }
17
+ }, async (input) => jsonResult(await coordinator.discoverCapabilities(input)));
18
+ server.registerTool('sync_plan_course', {
19
+ description: 'Create an immutable read-only plan for one-way course synchronization.',
20
+ inputSchema: {
21
+ source_profile: z.string().min(1), source_course_id: z.number().int().positive(),
22
+ target_profile: z.string().min(1), target_course_id: z.number().int().positive().optional(),
23
+ create_target_category_id: z.number().int().positive().optional(),
24
+ target_shortname: z.string().min(1).optional(),
25
+ unsupported_policy: z.enum(['error', 'skip', 'degrade']).optional(),
26
+ conflict_policy: z.enum(['abort', 'source-wins', 'target-wins', 'report']).optional()
27
+ }
28
+ }, async (input) => jsonResult(await coordinator.planCourse(input)));
29
+ server.registerTool('sync_apply_plan', {
30
+ description: 'Queue an externally approved immutable plan.',
31
+ inputSchema: { plan_id: z.string().min(1), plan_digest: z.string().min(1) }
32
+ }, async (input) => jsonResult(coordinator.startPlan(input)));
33
+ server.registerTool('sync_get_plan', {
34
+ description: 'Read one authorized immutable plan section with bounded pagination.',
35
+ inputSchema: {
36
+ plan_id: z.string().min(1),
37
+ section: z.enum([
38
+ 'actions', 'conflicts', 'divergences', 'unsupported', 'skipped', 'unchanged', 'unknown'
39
+ ]).optional(),
40
+ cursor: z.number().int().nonnegative().optional(),
41
+ limit: z.number().int().min(1).max(100).optional()
42
+ }
43
+ }, async (input) => jsonResult(coordinator.getPlan(input)));
44
+ server.registerTool('sync_get_job', {
45
+ description: 'Read a synchronization job and its verification status.',
46
+ inputSchema: { job_id: z.string().min(1) }
47
+ }, async ({ job_id }) => jsonResult(coordinator.getJob(job_id)));
48
+ server.registerTool('sync_cancel_job', {
49
+ description: 'Request that a running coordinator stop scheduling new actions.',
50
+ inputSchema: { job_id: z.string().min(1) }
51
+ }, async ({ job_id }) => jsonResult(coordinator.requestCancellation(job_id)));
52
+ server.registerTool('sync_resume_job', {
53
+ description: 'Resume an interrupted job after reconciliation and exact external re-approval.',
54
+ inputSchema: { job_id: z.string().min(1), plan_digest: z.string().min(1) }
55
+ }, async (input) => jsonResult(coordinator.resumeJob(input)));
56
+ server.registerTool('sync_get_conflicts', {
57
+ description: 'Read conflicts and target-only divergences in an immutable plan.',
58
+ inputSchema: { plan_id: z.string().min(1) }
59
+ }, async ({ plan_id }) => jsonResult(coordinator.getConflicts(plan_id)));
60
+ server.registerTool('sync_resolve_conflict', {
61
+ description: 'Replan current conflicts with an explicit policy; no writes are performed.',
62
+ inputSchema: { plan_id: z.string().min(1), resolution: z.enum(['source-wins', 'target-wins']) }
63
+ }, async (input) => jsonResult(await coordinator.resolveConflicts(input)));
64
+ server.registerTool('sync_verify_course', {
65
+ description: 'Re-read and verify a synchronization job without performing content writes.',
66
+ inputSchema: { plan_id: z.string().min(1), job_id: z.string().min(1).optional() }
67
+ }, async (input) => jsonResult(await coordinator.verifyCourse(input)));
68
+ server.registerTool('sync_get_history', {
69
+ description: 'List durable jobs visible to this policy-scoped coordinator.', inputSchema: {}
70
+ }, async () => jsonResult({ jobs: coordinator.listHistory() }));
71
+
72
+ return server;
73
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createSyncCoordinator } from './coordinator.mjs';
4
+ import { createMcpServer } from './mcp-server.mjs';
5
+
6
+ function requiredEnvironment(name) {
7
+ const value = process.env[name];
8
+ if (!value) throw new TypeError(`${name} is required.`);
9
+ return value;
10
+ }
11
+
12
+ function printHelp() {
13
+ process.stdout.write(`Usage: moodlia-sync-mcp\n\nEnvironment:\n MOODLIA_SYNC_CONFIG Profile configuration JSON path (required)\n MOODLIA_SYNC_POLICY Coordinator policy JSON path (required)\n MOODLIA_SYNC_STATE SQLite state path (default: .moodle-sync/coordinator.sqlite)\n`);
14
+ }
15
+
16
+ if (process.argv.slice(2).some((argument) => argument === '--help' || argument === '-h')) {
17
+ printHelp();
18
+ process.exit(0);
19
+ }
20
+
21
+ const coordinator = createSyncCoordinator({
22
+ configPath: requiredEnvironment('MOODLIA_SYNC_CONFIG'),
23
+ policyPath: requiredEnvironment('MOODLIA_SYNC_POLICY'),
24
+ statePath: process.env.MOODLIA_SYNC_STATE ?? '.moodle-sync/coordinator.sqlite'
25
+ });
26
+ const server = createMcpServer(coordinator);
27
+ await server.connect(new StdioServerTransport());
28
+
29
+ for (const signal of ['SIGINT', 'SIGTERM']) {
30
+ process.once(signal, async () => {
31
+ await coordinator.close();
32
+ await server.close();
33
+ process.exit(0);
34
+ });
35
+ }