pulsemcp-cms-admin-mcp-server 0.6.14 → 0.7.1
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/README.md +61 -58
- package/build/shared/src/exam-result-store.js +171 -0
- package/build/shared/src/tools/get-exam-result.js +98 -0
- package/build/shared/src/tools/run-exam-for-mirror.js +59 -7
- package/build/shared/src/tools/save-results-for-mirror.js +95 -13
- package/build/shared/src/tools.js +5 -1
- package/package.json +1 -1
- package/shared/exam-result-store.d.ts +68 -0
- package/shared/exam-result-store.js +171 -0
- package/shared/tools/get-exam-result.d.ts +40 -0
- package/shared/tools/get-exam-result.js +98 -0
- package/shared/tools/run-exam-for-mirror.js +59 -7
- package/shared/tools/save-results-for-mirror.d.ts +8 -4
- package/shared/tools/save-results-for-mirror.js +95 -13
- package/shared/tools.d.ts +4 -3
- package/shared/tools.js +5 -1
package/README.md
CHANGED
|
@@ -40,60 +40,61 @@ This is an MCP ([Model Context Protocol](https://modelcontextprotocol.io/)) Serv
|
|
|
40
40
|
|
|
41
41
|
This server is built and tested on macOS with Claude Desktop. It should work with other MCP clients as well.
|
|
42
42
|
|
|
43
|
-
| Tool Name | Tool Group | Read/Write | Description
|
|
44
|
-
| -------------------------------------- | ------------------ | ---------- |
|
|
45
|
-
| `get_newsletter_posts` | newsletter | read | List newsletter posts with search, sorting, and pagination options.
|
|
46
|
-
| `get_newsletter_post` | newsletter | read | Retrieve a specific newsletter post by its unique slug.
|
|
47
|
-
| `draft_newsletter_post` | newsletter | write | Create a new draft newsletter post with title, body, and metadata.
|
|
48
|
-
| `update_newsletter_post` | newsletter | write | Update an existing newsletter post's content and metadata (except status).
|
|
49
|
-
| `upload_image` | newsletter | write | Upload an image and attach it to a specific newsletter post.
|
|
50
|
-
| `get_authors` | newsletter | read | Get a list of authors with optional search and pagination.
|
|
51
|
-
| `search_mcp_implementations` | server_directory | read | Search for MCP servers and clients in the PulseMCP registry.
|
|
52
|
-
| `get_draft_mcp_implementations` | server_directory | read | Retrieve paginated list of draft MCP implementations needing review.
|
|
53
|
-
| `find_providers` | server_directory | read | Search for providers by ID, name, URL, or slug.
|
|
54
|
-
| `save_mcp_implementation` | server_directory | write | Update an MCP implementation (replicates Admin panel "Save Changes" button).
|
|
55
|
-
| `send_impl_posted_notif` | server_directory | write | Send email notification when MCP implementation goes live.
|
|
56
|
-
| `get_official_mirror_queue_items` | official_queue | read | List and filter official mirror queue entries with pagination and search.
|
|
57
|
-
| `get_official_mirror_queue_item` | official_queue | read | Get detailed information about a single official mirror queue entry.
|
|
58
|
-
| `approve_official_mirror_queue_item` | official_queue | write | Approve a queue entry and link it to an existing MCP server (async).
|
|
59
|
-
| `approve_mirror_no_modify` | official_queue | write | Approve without updating the linked server.
|
|
60
|
-
| `reject_official_mirror_queue_item` | official_queue | write | Reject a queue entry (async operation).
|
|
61
|
-
| `add_official_mirror_to_regular_queue` | official_queue | write | Convert a queue entry to a draft MCP implementation (async).
|
|
62
|
-
| `unlink_official_mirror_queue_item` | official_queue | write | Unlink a queue entry from its linked MCP server.
|
|
63
|
-
| `get_unofficial_mirrors` | unofficial_mirrors | read | List unofficial mirrors with search, pagination, and MCP server filtering.
|
|
64
|
-
| `get_unofficial_mirror` | unofficial_mirrors | read | Get detailed unofficial mirror info by ID or name.
|
|
65
|
-
| `create_unofficial_mirror` | unofficial_mirrors | write | Create a new unofficial mirror entry with JSON data.
|
|
66
|
-
| `update_unofficial_mirror` | unofficial_mirrors | write | Update an existing unofficial mirror by ID.
|
|
67
|
-
| `delete_unofficial_mirror` | unofficial_mirrors | write | Delete an unofficial mirror by ID (irreversible).
|
|
68
|
-
| `get_official_mirrors` | official_mirrors | read | List official mirrors with search, status, and processing filters.
|
|
69
|
-
| `get_official_mirror` | official_mirrors | read | Get detailed official mirror info by ID or name.
|
|
70
|
-
| `get_tenants` | tenants | read | List tenants with search and admin status filtering.
|
|
71
|
-
| `get_tenant` | tenants | read | Get detailed tenant info by ID or slug.
|
|
72
|
-
| `get_mcp_jsons` | mcp_jsons | read | List MCP JSON configs with mirror and server filtering.
|
|
73
|
-
| `get_mcp_json` | mcp_jsons | read | Get a single MCP JSON configuration by ID.
|
|
74
|
-
| `create_mcp_json` | mcp_jsons | write | Create a new MCP JSON configuration for an unofficial mirror.
|
|
75
|
-
| `update_mcp_json` | mcp_jsons | write | Update an existing MCP JSON configuration by ID.
|
|
76
|
-
| `delete_mcp_json` | mcp_jsons | write | Delete an MCP JSON configuration by ID (irreversible).
|
|
77
|
-
| `list_mcp_servers` | mcp_servers | read | List/search MCP servers with filtering by status, classification, pagination.
|
|
78
|
-
| `get_mcp_server` | mcp_servers | read | Get detailed MCP server info by slug (unified view of all admin UI fields).
|
|
79
|
-
| `update_mcp_server` | mcp_servers | write | Update an MCP server's fields (all admin UI fields supported).
|
|
80
|
-
| `get_redirects` | redirects | read | List URL redirects with search, status filtering, and pagination.
|
|
81
|
-
| `get_redirect` | redirects | read | Get detailed redirect info by ID.
|
|
82
|
-
| `create_redirect` | redirects | write | Create a new URL redirect entry.
|
|
83
|
-
| `update_redirect` | redirects | write | Update an existing URL redirect by ID.
|
|
84
|
-
| `delete_redirect` | redirects | write | Delete a URL redirect by ID (irreversible).
|
|
85
|
-
| `list_good_jobs` | good_jobs | read | List and filter background jobs by queue, status, job class, and date range.
|
|
86
|
-
| `get_good_job` | good_jobs | read | Get detailed information about a specific background job.
|
|
87
|
-
| `list_good_job_cron_schedules` | good_jobs | read | List all configured cron schedules.
|
|
88
|
-
| `list_good_job_processes` | good_jobs | read | List active worker processes.
|
|
89
|
-
| `get_good_job_queue_statistics` | good_jobs | read | Get aggregate job statistics by status.
|
|
90
|
-
| `retry_good_job` | good_jobs | write | Retry a failed or discarded background job.
|
|
91
|
-
| `discard_good_job` | good_jobs | write | Discard a background job to prevent retries.
|
|
92
|
-
| `reschedule_good_job` | good_jobs | write | Reschedule a background job to a new time.
|
|
93
|
-
| `force_trigger_good_job_cron` | good_jobs | write | Force trigger a cron schedule immediately.
|
|
94
|
-
| `cleanup_good_jobs` | good_jobs | write | Clean up old background jobs by status and age.
|
|
95
|
-
| `run_exam_for_mirror` | proctor | write | Run proctor exams against unofficial mirrors via Fly Machines.
|
|
96
|
-
| `
|
|
43
|
+
| Tool Name | Tool Group | Read/Write | Description |
|
|
44
|
+
| -------------------------------------- | ------------------ | ---------- | ---------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `get_newsletter_posts` | newsletter | read | List newsletter posts with search, sorting, and pagination options. |
|
|
46
|
+
| `get_newsletter_post` | newsletter | read | Retrieve a specific newsletter post by its unique slug. |
|
|
47
|
+
| `draft_newsletter_post` | newsletter | write | Create a new draft newsletter post with title, body, and metadata. |
|
|
48
|
+
| `update_newsletter_post` | newsletter | write | Update an existing newsletter post's content and metadata (except status). |
|
|
49
|
+
| `upload_image` | newsletter | write | Upload an image and attach it to a specific newsletter post. |
|
|
50
|
+
| `get_authors` | newsletter | read | Get a list of authors with optional search and pagination. |
|
|
51
|
+
| `search_mcp_implementations` | server_directory | read | Search for MCP servers and clients in the PulseMCP registry. |
|
|
52
|
+
| `get_draft_mcp_implementations` | server_directory | read | Retrieve paginated list of draft MCP implementations needing review. |
|
|
53
|
+
| `find_providers` | server_directory | read | Search for providers by ID, name, URL, or slug. |
|
|
54
|
+
| `save_mcp_implementation` | server_directory | write | Update an MCP implementation (replicates Admin panel "Save Changes" button). |
|
|
55
|
+
| `send_impl_posted_notif` | server_directory | write | Send email notification when MCP implementation goes live. |
|
|
56
|
+
| `get_official_mirror_queue_items` | official_queue | read | List and filter official mirror queue entries with pagination and search. |
|
|
57
|
+
| `get_official_mirror_queue_item` | official_queue | read | Get detailed information about a single official mirror queue entry. |
|
|
58
|
+
| `approve_official_mirror_queue_item` | official_queue | write | Approve a queue entry and link it to an existing MCP server (async). |
|
|
59
|
+
| `approve_mirror_no_modify` | official_queue | write | Approve without updating the linked server. |
|
|
60
|
+
| `reject_official_mirror_queue_item` | official_queue | write | Reject a queue entry (async operation). |
|
|
61
|
+
| `add_official_mirror_to_regular_queue` | official_queue | write | Convert a queue entry to a draft MCP implementation (async). |
|
|
62
|
+
| `unlink_official_mirror_queue_item` | official_queue | write | Unlink a queue entry from its linked MCP server. |
|
|
63
|
+
| `get_unofficial_mirrors` | unofficial_mirrors | read | List unofficial mirrors with search, pagination, and MCP server filtering. |
|
|
64
|
+
| `get_unofficial_mirror` | unofficial_mirrors | read | Get detailed unofficial mirror info by ID or name. |
|
|
65
|
+
| `create_unofficial_mirror` | unofficial_mirrors | write | Create a new unofficial mirror entry with JSON data. |
|
|
66
|
+
| `update_unofficial_mirror` | unofficial_mirrors | write | Update an existing unofficial mirror by ID. |
|
|
67
|
+
| `delete_unofficial_mirror` | unofficial_mirrors | write | Delete an unofficial mirror by ID (irreversible). |
|
|
68
|
+
| `get_official_mirrors` | official_mirrors | read | List official mirrors with search, status, and processing filters. |
|
|
69
|
+
| `get_official_mirror` | official_mirrors | read | Get detailed official mirror info by ID or name. |
|
|
70
|
+
| `get_tenants` | tenants | read | List tenants with search and admin status filtering. |
|
|
71
|
+
| `get_tenant` | tenants | read | Get detailed tenant info by ID or slug. |
|
|
72
|
+
| `get_mcp_jsons` | mcp_jsons | read | List MCP JSON configs with mirror and server filtering. |
|
|
73
|
+
| `get_mcp_json` | mcp_jsons | read | Get a single MCP JSON configuration by ID. |
|
|
74
|
+
| `create_mcp_json` | mcp_jsons | write | Create a new MCP JSON configuration for an unofficial mirror. |
|
|
75
|
+
| `update_mcp_json` | mcp_jsons | write | Update an existing MCP JSON configuration by ID. |
|
|
76
|
+
| `delete_mcp_json` | mcp_jsons | write | Delete an MCP JSON configuration by ID (irreversible). |
|
|
77
|
+
| `list_mcp_servers` | mcp_servers | read | List/search MCP servers with filtering by status, classification, pagination. |
|
|
78
|
+
| `get_mcp_server` | mcp_servers | read | Get detailed MCP server info by slug (unified view of all admin UI fields). |
|
|
79
|
+
| `update_mcp_server` | mcp_servers | write | Update an MCP server's fields (all admin UI fields supported). |
|
|
80
|
+
| `get_redirects` | redirects | read | List URL redirects with search, status filtering, and pagination. |
|
|
81
|
+
| `get_redirect` | redirects | read | Get detailed redirect info by ID. |
|
|
82
|
+
| `create_redirect` | redirects | write | Create a new URL redirect entry. |
|
|
83
|
+
| `update_redirect` | redirects | write | Update an existing URL redirect by ID. |
|
|
84
|
+
| `delete_redirect` | redirects | write | Delete a URL redirect by ID (irreversible). |
|
|
85
|
+
| `list_good_jobs` | good_jobs | read | List and filter background jobs by queue, status, job class, and date range. |
|
|
86
|
+
| `get_good_job` | good_jobs | read | Get detailed information about a specific background job. |
|
|
87
|
+
| `list_good_job_cron_schedules` | good_jobs | read | List all configured cron schedules. |
|
|
88
|
+
| `list_good_job_processes` | good_jobs | read | List active worker processes. |
|
|
89
|
+
| `get_good_job_queue_statistics` | good_jobs | read | Get aggregate job statistics by status. |
|
|
90
|
+
| `retry_good_job` | good_jobs | write | Retry a failed or discarded background job. |
|
|
91
|
+
| `discard_good_job` | good_jobs | write | Discard a background job to prevent retries. |
|
|
92
|
+
| `reschedule_good_job` | good_jobs | write | Reschedule a background job to a new time. |
|
|
93
|
+
| `force_trigger_good_job_cron` | good_jobs | write | Force trigger a cron schedule immediately. |
|
|
94
|
+
| `cleanup_good_jobs` | good_jobs | write | Clean up old background jobs by status and age. |
|
|
95
|
+
| `run_exam_for_mirror` | proctor | write | Run proctor exams against unofficial mirrors via Fly Machines. Returns truncated summary with `result_id`. |
|
|
96
|
+
| `get_exam_result` | proctor | read | Retrieve full untruncated exam results by `result_id`, with optional section/mirror filtering. |
|
|
97
|
+
| `save_results_for_mirror` | proctor | write | Save proctor exam results. Accepts `result_id` or explicit results array. |
|
|
97
98
|
|
|
98
99
|
# Tool Groups
|
|
99
100
|
|
|
@@ -126,7 +127,8 @@ This server organizes tools into groups that can be selectively enabled or disab
|
|
|
126
127
|
| `redirects_readonly` | 2 | URL redirects read-only (list, get) |
|
|
127
128
|
| `good_jobs` | 10 | Full GoodJob background job management (read + write) |
|
|
128
129
|
| `good_jobs_readonly` | 5 | GoodJob read-only (list, get, stats, processes, cron) |
|
|
129
|
-
| `proctor` |
|
|
130
|
+
| `proctor` | 3 | Proctor exam execution, result retrieval, and result storage (read + write) |
|
|
131
|
+
| `proctor_readonly` | 1 | Proctor results read-only (`get_exam_result`) |
|
|
130
132
|
|
|
131
133
|
### Tools by Group
|
|
132
134
|
|
|
@@ -158,7 +160,8 @@ This server organizes tools into groups that can be selectively enabled or disab
|
|
|
158
160
|
- **good_jobs** / **good_jobs_readonly**:
|
|
159
161
|
- Read-only: `list_good_jobs`, `get_good_job`, `list_good_job_cron_schedules`, `list_good_job_processes`, `get_good_job_queue_statistics`
|
|
160
162
|
- Write: `retry_good_job`, `discard_good_job`, `reschedule_good_job`, `force_trigger_good_job_cron`, `cleanup_good_jobs`
|
|
161
|
-
- **proctor**
|
|
163
|
+
- **proctor** / **proctor_readonly**:
|
|
164
|
+
- Read-only: `get_exam_result`
|
|
162
165
|
- Write: `run_exam_for_mirror`, `save_results_for_mirror`
|
|
163
166
|
|
|
164
167
|
## Environment Variables
|
|
@@ -190,10 +193,10 @@ TOOL_GROUPS=server_directory_readonly
|
|
|
190
193
|
Enable all groups with read-only access:
|
|
191
194
|
|
|
192
195
|
```bash
|
|
193
|
-
TOOL_GROUPS=newsletter_readonly,server_directory_readonly,official_queue_readonly,unofficial_mirrors_readonly,official_mirrors_readonly,tenants_readonly,mcp_jsons_readonly,mcp_servers_readonly,redirects_readonly,good_jobs_readonly
|
|
196
|
+
TOOL_GROUPS=newsletter_readonly,server_directory_readonly,official_queue_readonly,unofficial_mirrors_readonly,official_mirrors_readonly,tenants_readonly,mcp_jsons_readonly,mcp_servers_readonly,redirects_readonly,good_jobs_readonly,proctor_readonly
|
|
194
197
|
```
|
|
195
198
|
|
|
196
|
-
Note: `
|
|
199
|
+
Note: `proctor_readonly` includes only `get_exam_result` for retrieving stored results. `notifications` has no readonly variant since sending emails is always a write operation.
|
|
197
200
|
|
|
198
201
|
Mix full and read-only access per group:
|
|
199
202
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
/**
|
|
6
|
+
* Extract exam_id from a proctor exam stream line, checking both the
|
|
7
|
+
* data payload and top-level fields. The API may place exam_id in
|
|
8
|
+
* either location depending on the exam type.
|
|
9
|
+
*/
|
|
10
|
+
export function extractExamId(line) {
|
|
11
|
+
const data = line.data;
|
|
12
|
+
return (data?.exam_id ||
|
|
13
|
+
line.exam_id ||
|
|
14
|
+
data?.exam_type ||
|
|
15
|
+
line.exam_type ||
|
|
16
|
+
'unknown');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Extract status from a proctor exam stream line, checking both the
|
|
20
|
+
* data payload and top-level fields.
|
|
21
|
+
*/
|
|
22
|
+
export function extractStatus(line) {
|
|
23
|
+
const data = line.data;
|
|
24
|
+
return data?.status || line.status || 'unknown';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Maximum number of results to keep on disk. Oldest results are evicted
|
|
28
|
+
* when this limit is reached (FIFO by insertion order).
|
|
29
|
+
*/
|
|
30
|
+
const MAX_RESULTS = 100;
|
|
31
|
+
const STORE_DIR = join(tmpdir(), 'pulsemcp-exam-results');
|
|
32
|
+
const FILE_SUFFIX = '.json';
|
|
33
|
+
/**
|
|
34
|
+
* File-based store for proctor exam results.
|
|
35
|
+
*
|
|
36
|
+
* When `run_exam_for_mirror` completes, the full result is written to a
|
|
37
|
+
* JSON file in /tmp/ and a UUID `result_id` is returned. This avoids
|
|
38
|
+
* dumping large payloads (~60KB+ for servers with many tools) into the
|
|
39
|
+
* LLM context, and survives across tool calls without relying on
|
|
40
|
+
* in-memory state.
|
|
41
|
+
*
|
|
42
|
+
* Files are named with a zero-padded sequence number prefix so that
|
|
43
|
+
* lexicographic sorting preserves insertion order for FIFO eviction.
|
|
44
|
+
* The sequence counter is initialized from existing files on disk so
|
|
45
|
+
* that new entries sort after old ones even across process restarts.
|
|
46
|
+
*
|
|
47
|
+
* Eviction: When the store exceeds MAX_RESULTS files, the oldest result
|
|
48
|
+
* is evicted (FIFO). Results are also deleted after successful save via
|
|
49
|
+
* `save_results_for_mirror`.
|
|
50
|
+
*
|
|
51
|
+
* Consumers can:
|
|
52
|
+
* - Use `get_exam_result` to drill into the full result on demand
|
|
53
|
+
* - Pass `result_id` to `save_results_for_mirror` instead of the full payload
|
|
54
|
+
*/
|
|
55
|
+
class ExamResultStore {
|
|
56
|
+
seq;
|
|
57
|
+
constructor() {
|
|
58
|
+
this.seq = this.initSeqFromDisk();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Scan existing files to find the highest sequence number and start
|
|
62
|
+
* one past it. This ensures new files always sort after existing ones,
|
|
63
|
+
* even across process restarts.
|
|
64
|
+
*/
|
|
65
|
+
initSeqFromDisk() {
|
|
66
|
+
this.ensureDir();
|
|
67
|
+
const files = readdirSync(STORE_DIR)
|
|
68
|
+
.filter((f) => f.endsWith(FILE_SUFFIX) && f.length > FILE_SUFFIX.length)
|
|
69
|
+
.sort();
|
|
70
|
+
if (files.length === 0)
|
|
71
|
+
return 0;
|
|
72
|
+
const lastFile = files[files.length - 1];
|
|
73
|
+
const seqStr = lastFile.slice(0, 10);
|
|
74
|
+
const parsed = parseInt(seqStr, 10);
|
|
75
|
+
return isNaN(parsed) ? 0 : parsed + 1;
|
|
76
|
+
}
|
|
77
|
+
ensureDir() {
|
|
78
|
+
if (!existsSync(STORE_DIR)) {
|
|
79
|
+
mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Filename format: {seq}-{uuid}.json — seq is zero-padded for lexicographic ordering */
|
|
83
|
+
fileName(seq, resultId) {
|
|
84
|
+
return `${String(seq).padStart(10, '0')}-${resultId}${FILE_SUFFIX}`;
|
|
85
|
+
}
|
|
86
|
+
extractResultId(fileName) {
|
|
87
|
+
// Format: 0000000001-<uuid>.json
|
|
88
|
+
return fileName.slice(11, -FILE_SUFFIX.length);
|
|
89
|
+
}
|
|
90
|
+
listResultFiles() {
|
|
91
|
+
this.ensureDir();
|
|
92
|
+
return readdirSync(STORE_DIR)
|
|
93
|
+
.filter((f) => f.endsWith(FILE_SUFFIX) && f.length > FILE_SUFFIX.length)
|
|
94
|
+
.sort(); // Lexicographic sort gives insertion order via zero-padded seq
|
|
95
|
+
}
|
|
96
|
+
findFileForResult(resultId) {
|
|
97
|
+
const files = this.listResultFiles();
|
|
98
|
+
return files.find((f) => this.extractResultId(f) === resultId);
|
|
99
|
+
}
|
|
100
|
+
store(mirrorIds, runtimeId, examType, lines) {
|
|
101
|
+
this.ensureDir();
|
|
102
|
+
const resultId = randomUUID();
|
|
103
|
+
const seqNum = this.seq++;
|
|
104
|
+
// Evict oldest entries if at capacity (files are sorted by seq prefix)
|
|
105
|
+
const files = this.listResultFiles();
|
|
106
|
+
const toEvict = files.length - MAX_RESULTS + 1;
|
|
107
|
+
for (let i = 0; i < toEvict; i++) {
|
|
108
|
+
try {
|
|
109
|
+
unlinkSync(join(STORE_DIR, files[i]));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// ignore
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const stored = {
|
|
116
|
+
result_id: resultId,
|
|
117
|
+
mirror_ids: mirrorIds,
|
|
118
|
+
runtime_id: runtimeId,
|
|
119
|
+
exam_type: examType,
|
|
120
|
+
lines,
|
|
121
|
+
stored_at: new Date().toISOString(),
|
|
122
|
+
};
|
|
123
|
+
writeFileSync(join(STORE_DIR, this.fileName(seqNum, resultId)), JSON.stringify(stored), {
|
|
124
|
+
encoding: 'utf-8',
|
|
125
|
+
mode: 0o600,
|
|
126
|
+
});
|
|
127
|
+
return resultId;
|
|
128
|
+
}
|
|
129
|
+
get(resultId) {
|
|
130
|
+
const file = this.findFileForResult(resultId);
|
|
131
|
+
if (!file)
|
|
132
|
+
return undefined;
|
|
133
|
+
try {
|
|
134
|
+
const content = readFileSync(join(STORE_DIR, file), 'utf-8');
|
|
135
|
+
return JSON.parse(content);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
delete(resultId) {
|
|
142
|
+
const file = this.findFileForResult(resultId);
|
|
143
|
+
if (!file)
|
|
144
|
+
return false;
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(join(STORE_DIR, file));
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
get size() {
|
|
154
|
+
return this.listResultFiles().length;
|
|
155
|
+
}
|
|
156
|
+
/** For testing only — removes all result files and resets the sequence counter */
|
|
157
|
+
clear() {
|
|
158
|
+
this.ensureDir();
|
|
159
|
+
for (const file of this.listResultFiles()) {
|
|
160
|
+
try {
|
|
161
|
+
unlinkSync(join(STORE_DIR, file));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// ignore
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.seq = 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Singleton instance shared across all tool factories */
|
|
171
|
+
export const examResultStore = new ExamResultStore();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { examResultStore } from '../exam-result-store.js';
|
|
3
|
+
const PARAM_DESCRIPTIONS = {
|
|
4
|
+
result_id: 'The UUID returned by run_exam_for_mirror identifying the stored result to retrieve.',
|
|
5
|
+
section: 'Optional filter to retrieve only a specific section of the result. "exam_results" returns only exam_result lines, "logs" returns only log lines, "summary" returns only the summary line, "errors" returns only error lines. If omitted, returns all lines.',
|
|
6
|
+
mirror_id: 'Optional mirror ID filter. When provided, only returns exam_result lines for this specific mirror. Useful when the exam was run against multiple mirrors.',
|
|
7
|
+
};
|
|
8
|
+
const GetExamResultSchema = z.object({
|
|
9
|
+
result_id: z.string().uuid().describe(PARAM_DESCRIPTIONS.result_id),
|
|
10
|
+
section: z
|
|
11
|
+
.enum(['exam_results', 'logs', 'summary', 'errors'])
|
|
12
|
+
.optional()
|
|
13
|
+
.describe(PARAM_DESCRIPTIONS.section),
|
|
14
|
+
mirror_id: z.number().optional().describe(PARAM_DESCRIPTIONS.mirror_id),
|
|
15
|
+
});
|
|
16
|
+
export function getExamResult(_server, _clientFactory) {
|
|
17
|
+
return {
|
|
18
|
+
name: 'get_exam_result',
|
|
19
|
+
description: `Retrieve the full, untruncated proctor exam result stored by a prior run_exam_for_mirror call.
|
|
20
|
+
|
|
21
|
+
run_exam_for_mirror returns a truncated summary to keep the response within MCP size limits. This tool provides on-demand access to the complete result data, including full tool input schemas and detailed exam output.
|
|
22
|
+
|
|
23
|
+
Supports filtering by section (exam_results, logs, summary, errors) and by mirror_id to drill into specific parts of the result without loading the entire payload.
|
|
24
|
+
|
|
25
|
+
**Tip**: For servers with many tools, the full result can be very large. Use the section and/or mirror_id filters to retrieve only the data you need.
|
|
26
|
+
|
|
27
|
+
Typical usage:
|
|
28
|
+
1. Call run_exam_for_mirror — note the returned result_id
|
|
29
|
+
2. Call get_exam_result with that result_id and a section filter (e.g., section="exam_results")
|
|
30
|
+
3. Optionally add mirror_id to narrow further for multi-mirror exams`,
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
result_id: { type: 'string', format: 'uuid', description: PARAM_DESCRIPTIONS.result_id },
|
|
35
|
+
section: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
enum: ['exam_results', 'logs', 'summary', 'errors'],
|
|
38
|
+
description: PARAM_DESCRIPTIONS.section,
|
|
39
|
+
},
|
|
40
|
+
mirror_id: { type: 'number', description: PARAM_DESCRIPTIONS.mirror_id },
|
|
41
|
+
},
|
|
42
|
+
required: ['result_id'],
|
|
43
|
+
},
|
|
44
|
+
handler: async (args) => {
|
|
45
|
+
const validatedArgs = GetExamResultSchema.parse(args);
|
|
46
|
+
const stored = examResultStore.get(validatedArgs.result_id);
|
|
47
|
+
if (!stored) {
|
|
48
|
+
return {
|
|
49
|
+
content: [
|
|
50
|
+
{
|
|
51
|
+
type: 'text',
|
|
52
|
+
text: `No stored result found for result_id "${validatedArgs.result_id}". The result file may have been cleaned up or the /tmp directory cleared.`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
let lines = stored.lines;
|
|
59
|
+
// Filter by section
|
|
60
|
+
if (validatedArgs.section) {
|
|
61
|
+
const typeMap = {
|
|
62
|
+
exam_results: 'exam_result',
|
|
63
|
+
logs: 'log',
|
|
64
|
+
summary: 'summary',
|
|
65
|
+
errors: 'error',
|
|
66
|
+
};
|
|
67
|
+
const targetType = typeMap[validatedArgs.section];
|
|
68
|
+
lines = lines.filter((line) => line.type === targetType);
|
|
69
|
+
}
|
|
70
|
+
// Filter by mirror_id
|
|
71
|
+
if (validatedArgs.mirror_id !== undefined) {
|
|
72
|
+
lines = lines.filter((line) => line.type !== 'exam_result' || line.mirror_id === validatedArgs.mirror_id);
|
|
73
|
+
}
|
|
74
|
+
let content = `**Exam Result Details**\n\n`;
|
|
75
|
+
content += `Result ID: ${stored.result_id}\n`;
|
|
76
|
+
content += `Mirrors: ${stored.mirror_ids.join(', ')}\n`;
|
|
77
|
+
content += `Exam Type: ${stored.exam_type}\n`;
|
|
78
|
+
content += `Runtime: ${stored.runtime_id}\n`;
|
|
79
|
+
content += `Stored At: ${stored.stored_at}\n`;
|
|
80
|
+
if (validatedArgs.section) {
|
|
81
|
+
content += `Section Filter: ${validatedArgs.section}\n`;
|
|
82
|
+
}
|
|
83
|
+
if (validatedArgs.mirror_id !== undefined) {
|
|
84
|
+
content += `Mirror Filter: ${validatedArgs.mirror_id}\n`;
|
|
85
|
+
}
|
|
86
|
+
content += `\n---\n\n`;
|
|
87
|
+
if (lines.length === 0) {
|
|
88
|
+
content += 'No matching lines found for the given filters.\n';
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
content += JSON.stringify(line, null, 2) + '\n\n';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { content: [{ type: 'text', text: content.trim() }] };
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { examResultStore, extractExamId, extractStatus } from '../exam-result-store.js';
|
|
2
3
|
const PARAM_DESCRIPTIONS = {
|
|
3
4
|
mirror_ids: 'Array of unofficial mirror IDs to run exams against. Mirrors without saved mcp_json configs will be skipped.',
|
|
4
5
|
runtime_id: 'The Fly Machines runtime ID to use for running the exam containers (e.g., "fly-machines-v1")',
|
|
@@ -13,6 +14,46 @@ const RunExamForMirrorSchema = z.object({
|
|
|
13
14
|
.describe(PARAM_DESCRIPTIONS.exam_type),
|
|
14
15
|
max_retries: z.number().min(0).max(10).optional().describe(PARAM_DESCRIPTIONS.max_retries),
|
|
15
16
|
});
|
|
17
|
+
/**
|
|
18
|
+
* Build a truncated summary of exam results for the LLM response.
|
|
19
|
+
* Omits large keys like full input schemas from tool listings to keep
|
|
20
|
+
* the payload within MCP size limits. The full result is accessible
|
|
21
|
+
* via the `get_exam_result` tool using the returned `result_id`.
|
|
22
|
+
*/
|
|
23
|
+
function truncateExamResultData(data) {
|
|
24
|
+
const truncated = {};
|
|
25
|
+
for (const [key, value] of Object.entries(data)) {
|
|
26
|
+
if (key === 'tools' && Array.isArray(value)) {
|
|
27
|
+
// For tool listings, include only name and description, omit inputSchema
|
|
28
|
+
truncated[key] = value.map((tool) => ({
|
|
29
|
+
name: tool.name,
|
|
30
|
+
...(tool.description ? { description: String(tool.description).slice(0, 100) } : {}),
|
|
31
|
+
}));
|
|
32
|
+
truncated['tools_count'] = value.length;
|
|
33
|
+
truncated['tools_truncated'] = true;
|
|
34
|
+
}
|
|
35
|
+
else if (key === 'inputSchema' || key === 'input_schema') {
|
|
36
|
+
// Omit full input schemas
|
|
37
|
+
truncated[key] = '(truncated — use get_exam_result to see full data)';
|
|
38
|
+
}
|
|
39
|
+
else if (typeof value === 'string' && value.length > 500) {
|
|
40
|
+
truncated[key] = value.slice(0, 500) + '... (truncated)';
|
|
41
|
+
}
|
|
42
|
+
else if (typeof value === 'object' && value !== null) {
|
|
43
|
+
const serialized = JSON.stringify(value);
|
|
44
|
+
if (serialized.length > 1000) {
|
|
45
|
+
truncated[key] = '(truncated — use get_exam_result to see full data)';
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
truncated[key] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
truncated[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return truncated;
|
|
56
|
+
}
|
|
16
57
|
export function runExamForMirror(_server, clientFactory) {
|
|
17
58
|
return {
|
|
18
59
|
name: 'run_exam_for_mirror',
|
|
@@ -23,7 +64,9 @@ Available exam types:
|
|
|
23
64
|
- **init-tools-list**: Connects to the mirror and retrieves its list of MCP tools, verifying the server initializes properly
|
|
24
65
|
- **both**: Runs both exams sequentially
|
|
25
66
|
|
|
26
|
-
Mirrors without saved mcp_json configurations are automatically skipped.
|
|
67
|
+
Mirrors without saved mcp_json configurations are automatically skipped.
|
|
68
|
+
|
|
69
|
+
Results are stored server-side in a local file and a \`result_id\` UUID is returned. The response includes a truncated summary (status, tool names/counts, errors) that fits within MCP size limits. Use \`get_exam_result\` to drill into full details, or pass the \`result_id\` directly to \`save_results_for_mirror\`.
|
|
27
70
|
|
|
28
71
|
Use cases:
|
|
29
72
|
- Test if an unofficial mirror's MCP server is working correctly before linking it
|
|
@@ -64,7 +107,10 @@ Use cases:
|
|
|
64
107
|
exam_type: validatedArgs.exam_type,
|
|
65
108
|
max_retries: validatedArgs.max_retries,
|
|
66
109
|
});
|
|
110
|
+
// Store the full result server-side
|
|
111
|
+
const resultId = examResultStore.store(validatedArgs.mirror_ids, validatedArgs.runtime_id, validatedArgs.exam_type, response.lines);
|
|
67
112
|
let content = `**Proctor Exam Results**\n\n`;
|
|
113
|
+
content += `Result ID: ${resultId}\n`;
|
|
68
114
|
content += `Mirrors: ${validatedArgs.mirror_ids.join(', ')}\n`;
|
|
69
115
|
content += `Exam Type: ${validatedArgs.exam_type}\n`;
|
|
70
116
|
content += `Runtime: ${validatedArgs.runtime_id}\n\n`;
|
|
@@ -73,14 +119,18 @@ Use cases:
|
|
|
73
119
|
case 'log':
|
|
74
120
|
content += `[LOG] ${line.message || JSON.stringify(line)}\n`;
|
|
75
121
|
break;
|
|
76
|
-
case 'exam_result':
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
content +=
|
|
80
|
-
|
|
81
|
-
|
|
122
|
+
case 'exam_result': {
|
|
123
|
+
const data = line.data;
|
|
124
|
+
const mirrorId = line.mirror_id ?? data?.mirror_id ?? 'unknown';
|
|
125
|
+
content += `\n**Exam Result** (Mirror: ${mirrorId})\n`;
|
|
126
|
+
content += ` Exam: ${extractExamId(line)}\n`;
|
|
127
|
+
content += ` Status: ${extractStatus(line)}\n`;
|
|
128
|
+
if (data) {
|
|
129
|
+
const truncatedData = truncateExamResultData(data);
|
|
130
|
+
content += ` Data: ${JSON.stringify(truncatedData, null, 2)}\n`;
|
|
82
131
|
}
|
|
83
132
|
break;
|
|
133
|
+
}
|
|
84
134
|
case 'summary':
|
|
85
135
|
content += `\n**Summary**\n`;
|
|
86
136
|
content += ` Total: ${line.total || 0}\n`;
|
|
@@ -95,6 +145,8 @@ Use cases:
|
|
|
95
145
|
content += `${JSON.stringify(line)}\n`;
|
|
96
146
|
}
|
|
97
147
|
}
|
|
148
|
+
content += `\n\nUse \`get_exam_result\` with result_id "${resultId}" to see full untruncated data.`;
|
|
149
|
+
content += `\nUse \`save_results_for_mirror\` with result_id "${resultId}" to save these results.`;
|
|
98
150
|
return { content: [{ type: 'text', text: content.trim() }] };
|
|
99
151
|
}
|
|
100
152
|
catch (error) {
|