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 +5 -0
- package/README.md +95 -0
- package/package.json +40 -0
- package/server/coordinator.mjs +369 -0
- package/server/http.mjs +118 -0
- package/server/mcp-server.mjs +73 -0
- package/server/stdio.mjs +35 -0
package/LICENSE
ADDED
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
|
+
}
|
package/server/http.mjs
ADDED
|
@@ -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
|
+
}
|
package/server/stdio.mjs
ADDED
|
@@ -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
|
+
}
|