@the-cascade-protocol/cli 0.2.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/.dockerignore +7 -0
- package/.eslintrc.json +23 -0
- package/.prettierrc +7 -0
- package/DOCKER.md +36 -0
- package/Dockerfile +18 -0
- package/README.md +69 -0
- package/dist/commands/capabilities.d.ts +9 -0
- package/dist/commands/capabilities.d.ts.map +1 -0
- package/dist/commands/capabilities.js +194 -0
- package/dist/commands/capabilities.js.map +1 -0
- package/dist/commands/conformance.d.ts +15 -0
- package/dist/commands/conformance.d.ts.map +1 -0
- package/dist/commands/conformance.js +348 -0
- package/dist/commands/conformance.js.map +1 -0
- package/dist/commands/convert.d.ts +21 -0
- package/dist/commands/convert.d.ts.map +1 -0
- package/dist/commands/convert.js +134 -0
- package/dist/commands/convert.js.map +1 -0
- package/dist/commands/pod/export.d.ts +8 -0
- package/dist/commands/pod/export.d.ts.map +1 -0
- package/dist/commands/pod/export.js +72 -0
- package/dist/commands/pod/export.js.map +1 -0
- package/dist/commands/pod/helpers.d.ts +79 -0
- package/dist/commands/pod/helpers.d.ts.map +1 -0
- package/dist/commands/pod/helpers.js +369 -0
- package/dist/commands/pod/helpers.js.map +1 -0
- package/dist/commands/pod/index.d.ts +20 -0
- package/dist/commands/pod/index.d.ts.map +1 -0
- package/dist/commands/pod/index.js +29 -0
- package/dist/commands/pod/index.js.map +1 -0
- package/dist/commands/pod/info.d.ts +9 -0
- package/dist/commands/pod/info.d.ts.map +1 -0
- package/dist/commands/pod/info.js +196 -0
- package/dist/commands/pod/info.js.map +1 -0
- package/dist/commands/pod/init.d.ts +9 -0
- package/dist/commands/pod/init.d.ts.map +1 -0
- package/dist/commands/pod/init.js +251 -0
- package/dist/commands/pod/init.js.map +1 -0
- package/dist/commands/pod/query.d.ts +9 -0
- package/dist/commands/pod/query.d.ts.map +1 -0
- package/dist/commands/pod/query.js +169 -0
- package/dist/commands/pod/query.js.map +1 -0
- package/dist/commands/pod 2.js +1017 -0
- package/dist/commands/pod.d.ts +28 -0
- package/dist/commands/pod.d.ts 2.map +1 -0
- package/dist/commands/pod.d.ts.map +1 -0
- package/dist/commands/pod.js +1031 -0
- package/dist/commands/pod.js 2.map +1 -0
- package/dist/commands/pod.js.map +1 -0
- package/dist/commands/serve.d.ts +33 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +74 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/validate.d.ts +18 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +275 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.d.ts +17 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.d.ts.map +1 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.js +358 -0
- package/dist/lib/fhir-converter/cascade-to-fhir.js.map +1 -0
- package/dist/lib/fhir-converter/converters-clinical.d.ts +29 -0
- package/dist/lib/fhir-converter/converters-clinical.d.ts.map +1 -0
- package/dist/lib/fhir-converter/converters-clinical.js +391 -0
- package/dist/lib/fhir-converter/converters-clinical.js.map +1 -0
- package/dist/lib/fhir-converter/converters-demographics.d.ts +20 -0
- package/dist/lib/fhir-converter/converters-demographics.d.ts.map +1 -0
- package/dist/lib/fhir-converter/converters-demographics.js +242 -0
- package/dist/lib/fhir-converter/converters-demographics.js.map +1 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.d.ts +17 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.d.ts.map +1 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.js +63 -0
- package/dist/lib/fhir-converter/fhir-to-cascade.js.map +1 -0
- package/dist/lib/fhir-converter/index.d.ts +36 -0
- package/dist/lib/fhir-converter/index.d.ts.map +1 -0
- package/dist/lib/fhir-converter/index.js +187 -0
- package/dist/lib/fhir-converter/index.js.map +1 -0
- package/dist/lib/fhir-converter/types.d.ts +77 -0
- package/dist/lib/fhir-converter/types.d.ts.map +1 -0
- package/dist/lib/fhir-converter/types.js +236 -0
- package/dist/lib/fhir-converter/types.js.map +1 -0
- package/dist/lib/fhir-converter.d.ts +62 -0
- package/dist/lib/fhir-converter.d.ts.map +1 -0
- package/dist/lib/fhir-converter.js +1474 -0
- package/dist/lib/fhir-converter.js.map +1 -0
- package/dist/lib/mcp/audit.d.ts +24 -0
- package/dist/lib/mcp/audit.d.ts.map +1 -0
- package/dist/lib/mcp/audit.js +85 -0
- package/dist/lib/mcp/audit.js.map +1 -0
- package/dist/lib/mcp/server.d.ts +38 -0
- package/dist/lib/mcp/server.d.ts.map +1 -0
- package/dist/lib/mcp/server.js +172 -0
- package/dist/lib/mcp/server.js.map +1 -0
- package/dist/lib/mcp/tools.d.ts +47 -0
- package/dist/lib/mcp/tools.d.ts.map +1 -0
- package/dist/lib/mcp/tools.js +547 -0
- package/dist/lib/mcp/tools.js.map +1 -0
- package/dist/lib/output.d.ts +26 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +64 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/shacl-validator.d.ts +53 -0
- package/dist/lib/shacl-validator.d.ts.map +1 -0
- package/dist/lib/shacl-validator.js +245 -0
- package/dist/lib/shacl-validator.js.map +1 -0
- package/dist/lib/turtle-parser.d.ts +64 -0
- package/dist/lib/turtle-parser.d.ts.map +1 -0
- package/dist/lib/turtle-parser.js +236 -0
- package/dist/lib/turtle-parser.js.map +1 -0
- package/dist/shapes/checkup.shapes.ttl +1459 -0
- package/dist/shapes/clinical.shapes.ttl +1350 -0
- package/dist/shapes/clinical.ttl +1369 -0
- package/dist/shapes/core.shapes.ttl +450 -0
- package/dist/shapes/core.ttl +603 -0
- package/dist/shapes/coverage.shapes.ttl +214 -0
- package/dist/shapes/coverage.ttl +182 -0
- package/dist/shapes/health.shapes.ttl +697 -0
- package/dist/shapes/health.ttl +859 -0
- package/dist/shapes/pots.shapes.ttl +481 -0
- package/package.json +54 -0
- package/src/commands/capabilities.ts +235 -0
- package/src/commands/conformance.ts +447 -0
- package/src/commands/convert.ts +164 -0
- package/src/commands/pod/export.ts +85 -0
- package/src/commands/pod/helpers.ts +449 -0
- package/src/commands/pod/index.ts +32 -0
- package/src/commands/pod/info.ts +239 -0
- package/src/commands/pod/init.ts +273 -0
- package/src/commands/pod/query.ts +224 -0
- package/src/commands/serve.ts +92 -0
- package/src/commands/validate.ts +303 -0
- package/src/index.ts +58 -0
- package/src/lib/fhir-converter/cascade-to-fhir.ts +369 -0
- package/src/lib/fhir-converter/converters-clinical.ts +446 -0
- package/src/lib/fhir-converter/converters-demographics.ts +270 -0
- package/src/lib/fhir-converter/fhir-to-cascade.ts +82 -0
- package/src/lib/fhir-converter/index.ts +215 -0
- package/src/lib/fhir-converter/types.ts +318 -0
- package/src/lib/mcp/audit.ts +107 -0
- package/src/lib/mcp/server.ts +192 -0
- package/src/lib/mcp/tools.ts +668 -0
- package/src/lib/output.ts +76 -0
- package/src/lib/shacl-validator.ts +314 -0
- package/src/lib/turtle-parser.ts +277 -0
- package/src/shapes/checkup.shapes.ttl +1459 -0
- package/src/shapes/clinical.shapes.ttl +1350 -0
- package/src/shapes/clinical.ttl +1369 -0
- package/src/shapes/core.shapes.ttl +450 -0
- package/src/shapes/core.ttl +603 -0
- package/src/shapes/coverage.shapes.ttl +214 -0
- package/src/shapes/coverage.ttl +182 -0
- package/src/shapes/health.shapes.ttl +697 -0
- package/src/shapes/health.ttl +859 -0
- package/src/shapes/pots.shapes.ttl +481 -0
- package/test-fixtures/fhir-bundle-example.json +216 -0
- package/test-fixtures/fhir-medication-example.json +18 -0
- package/tests/cli.test.ts +126 -0
- package/tests/fhir-converter.test.ts +874 -0
- package/tests/mcp-server.test.ts +396 -0
- package/tests/pod.test.ts +400 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool definitions and handlers for the Cascade Protocol agent server.
|
|
3
|
+
*
|
|
4
|
+
* Exposes 6 tools:
|
|
5
|
+
* - cascade_pod_read: Read full Pod contents
|
|
6
|
+
* - cascade_pod_query: Query records by data type
|
|
7
|
+
* - cascade_validate: Validate Turtle against SHACL shapes
|
|
8
|
+
* - cascade_convert: Convert between FHIR and Cascade formats
|
|
9
|
+
* - cascade_write: Write a record to a Pod with provenance
|
|
10
|
+
* - cascade_capabilities: Describe all available tools
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs/promises';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import {
|
|
19
|
+
DATA_TYPES,
|
|
20
|
+
resolvePodDir,
|
|
21
|
+
isDirectory,
|
|
22
|
+
fileExists,
|
|
23
|
+
parseDataFile,
|
|
24
|
+
readPatientProfile,
|
|
25
|
+
} from '../../commands/pod/helpers.js';
|
|
26
|
+
import { loadShapes, validateTurtle, validateFile, findTurtleFiles } from '../shacl-validator.js';
|
|
27
|
+
import { convert } from '../fhir-converter/index.js';
|
|
28
|
+
import { writeAuditEntry, createAuditEntry } from './audit.js';
|
|
29
|
+
|
|
30
|
+
// ─── Shared State ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Default Pod path from environment or CLI option */
|
|
33
|
+
let defaultPodPath: string | undefined;
|
|
34
|
+
|
|
35
|
+
/** Lazily loaded SHACL shapes */
|
|
36
|
+
let shapesCache: { store: import('n3').Store; shapeFiles: string[] } | undefined;
|
|
37
|
+
|
|
38
|
+
function getShapes() {
|
|
39
|
+
if (!shapesCache) {
|
|
40
|
+
shapesCache = loadShapes();
|
|
41
|
+
}
|
|
42
|
+
return shapesCache;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setDefaultPodPath(podPath: string): void {
|
|
46
|
+
defaultPodPath = podPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolvePod(pathArg?: string): string {
|
|
50
|
+
const raw = pathArg ?? defaultPodPath;
|
|
51
|
+
if (!raw) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'No Pod path specified. Pass a "path" argument or set CASCADE_POD_PATH environment variable.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return resolvePodDir(raw);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate that a resolved path stays within an allowed boundary directory.
|
|
61
|
+
* Prevents path traversal attacks (e.g., "../../etc/passwd").
|
|
62
|
+
*/
|
|
63
|
+
export function validatePathBoundary(resolvedPath: string, boundary: string): boolean {
|
|
64
|
+
const normalizedPath = path.resolve(resolvedPath);
|
|
65
|
+
const normalizedBoundary = path.resolve(boundary);
|
|
66
|
+
return normalizedPath.startsWith(normalizedBoundary + path.sep) || normalizedPath === normalizedBoundary;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Format a successful tool response with JSON content. */
|
|
70
|
+
function toolResponse(data: unknown): { content: Array<{ type: 'text'; text: string }> } {
|
|
71
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Format an error tool response. */
|
|
75
|
+
function toolError(message: string): { content: Array<{ type: 'text'; text: string }> } {
|
|
76
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Wrapper for tool handlers that need Pod directory resolution.
|
|
81
|
+
* Handles: pod path resolution, directory check, error catching, response formatting.
|
|
82
|
+
*/
|
|
83
|
+
function withPodHandler(
|
|
84
|
+
handler: (absDir: string, args: Record<string, unknown>) => Promise<unknown>,
|
|
85
|
+
): (args: Record<string, unknown>) => Promise<{ content: Array<{ type: 'text'; text: string }> }> {
|
|
86
|
+
return async (args) => {
|
|
87
|
+
try {
|
|
88
|
+
const absDir = resolvePod(args.path as string | undefined);
|
|
89
|
+
if (!(await isDirectory(absDir))) {
|
|
90
|
+
return toolError('Pod directory not found. Check the path argument or CASCADE_POD_PATH variable.');
|
|
91
|
+
}
|
|
92
|
+
const result = await handler(absDir, args);
|
|
93
|
+
return toolResponse(result);
|
|
94
|
+
} catch (err: unknown) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
return toolError(message);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Tool Registration ──────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register all Cascade MCP tools on the given McpServer instance.
|
|
105
|
+
*/
|
|
106
|
+
export function registerTools(server: McpServer): void {
|
|
107
|
+
registerPodRead(server);
|
|
108
|
+
registerPodQuery(server);
|
|
109
|
+
registerValidate(server);
|
|
110
|
+
registerConvert(server);
|
|
111
|
+
registerWrite(server);
|
|
112
|
+
registerCapabilities(server);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── cascade_pod_read ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function registerPodRead(server: McpServer): void {
|
|
118
|
+
server.tool(
|
|
119
|
+
'cascade_pod_read',
|
|
120
|
+
'Read a Cascade Pod and return a JSON summary of all contents including patient profile, record counts, provenance sources, and data inventory.',
|
|
121
|
+
{
|
|
122
|
+
path: z.string().optional().describe('Path to the Pod directory. Uses CASCADE_POD_PATH if omitted.'),
|
|
123
|
+
},
|
|
124
|
+
withPodHandler(async (absDir) => {
|
|
125
|
+
const profile = await readPatientProfile(absDir);
|
|
126
|
+
|
|
127
|
+
const recordCounts: Record<string, number> = {};
|
|
128
|
+
const provenanceSources = new Set<string>();
|
|
129
|
+
let totalRecords = 0;
|
|
130
|
+
|
|
131
|
+
for (const [typeName, typeInfo] of Object.entries(DATA_TYPES)) {
|
|
132
|
+
const filePath = path.join(absDir, typeInfo.directory, typeInfo.filename);
|
|
133
|
+
if (!(await fileExists(filePath))) continue;
|
|
134
|
+
|
|
135
|
+
const { records } = await parseDataFile(filePath);
|
|
136
|
+
if (records.length > 0) {
|
|
137
|
+
recordCounts[typeName] = records.length;
|
|
138
|
+
totalRecords += records.length;
|
|
139
|
+
|
|
140
|
+
for (const rec of records) {
|
|
141
|
+
const prov = rec.properties['cascade:dataProvenance'];
|
|
142
|
+
if (prov) provenanceSources.add(prov);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await writeAuditEntry(
|
|
148
|
+
absDir,
|
|
149
|
+
createAuditEntry('pod_read', ['all'], totalRecords),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
pod: absDir,
|
|
154
|
+
patient: {
|
|
155
|
+
name: profile.name ?? 'Unknown',
|
|
156
|
+
dateOfBirth: profile.dateOfBirth,
|
|
157
|
+
age: profile.age,
|
|
158
|
+
schemaVersion: profile.schemaVersion,
|
|
159
|
+
},
|
|
160
|
+
totalRecords,
|
|
161
|
+
recordCounts,
|
|
162
|
+
provenanceSources: Array.from(provenanceSources),
|
|
163
|
+
directories: {
|
|
164
|
+
clinical: Object.entries(recordCounts)
|
|
165
|
+
.filter(([k]) => DATA_TYPES[k]?.directory === 'clinical')
|
|
166
|
+
.map(([k, v]) => ({ type: k, count: v })),
|
|
167
|
+
wellness: Object.entries(recordCounts)
|
|
168
|
+
.filter(([k]) => DATA_TYPES[k]?.directory === 'wellness')
|
|
169
|
+
.map(([k, v]) => ({ type: k, count: v })),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── cascade_pod_query ───────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function registerPodQuery(server: McpServer): void {
|
|
179
|
+
server.tool(
|
|
180
|
+
'cascade_pod_query',
|
|
181
|
+
'Query records from a Cascade Pod by data type. Returns JSON array of matching records with their properties and provenance.',
|
|
182
|
+
{
|
|
183
|
+
path: z.string().optional().describe('Path to the Pod directory. Uses CASCADE_POD_PATH if omitted.'),
|
|
184
|
+
dataType: z
|
|
185
|
+
.enum([
|
|
186
|
+
'medications', 'conditions', 'allergies', 'lab-results',
|
|
187
|
+
'immunizations', 'vital-signs', 'supplements', 'insurance',
|
|
188
|
+
'patient-profile', 'heart-rate', 'blood-pressure',
|
|
189
|
+
'activity', 'sleep', 'all',
|
|
190
|
+
])
|
|
191
|
+
.describe('Data type to query, or "all" for everything.'),
|
|
192
|
+
},
|
|
193
|
+
withPodHandler(async (absDir, args) => {
|
|
194
|
+
const dataType = args.dataType as string;
|
|
195
|
+
const typesToQuery = dataType === 'all' ? Object.keys(DATA_TYPES) : [dataType];
|
|
196
|
+
const results: Record<string, { count: number; file: string; records: Array<{ id: string; type: string; label?: string; properties: Record<string, string> }> }> = {};
|
|
197
|
+
let totalRecords = 0;
|
|
198
|
+
|
|
199
|
+
for (const typeName of typesToQuery) {
|
|
200
|
+
const typeInfo = DATA_TYPES[typeName];
|
|
201
|
+
if (!typeInfo) continue;
|
|
202
|
+
|
|
203
|
+
const filePath = path.join(absDir, typeInfo.directory, typeInfo.filename);
|
|
204
|
+
if (!(await fileExists(filePath))) continue;
|
|
205
|
+
|
|
206
|
+
const { records } = await parseDataFile(filePath);
|
|
207
|
+
if (records.length > 0) {
|
|
208
|
+
results[typeName] = {
|
|
209
|
+
count: records.length,
|
|
210
|
+
file: `${typeInfo.directory}/${typeInfo.filename}`,
|
|
211
|
+
records: records.map((r) => ({
|
|
212
|
+
id: r.id,
|
|
213
|
+
type: r.type,
|
|
214
|
+
label: r.label,
|
|
215
|
+
properties: r.properties,
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
totalRecords += records.length;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await writeAuditEntry(
|
|
223
|
+
absDir,
|
|
224
|
+
createAuditEntry('pod_query', typesToQuery, totalRecords),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return { pod: absDir, dataType, dataTypes: results, totalRecords };
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── cascade_validate ────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function registerValidate(server: McpServer): void {
|
|
235
|
+
server.tool(
|
|
236
|
+
'cascade_validate',
|
|
237
|
+
'Validate Cascade Protocol Turtle data against SHACL shapes. Accepts either a file/directory path or inline Turtle content.',
|
|
238
|
+
{
|
|
239
|
+
path: z.string().optional().describe('Path to a Turtle file or directory to validate.'),
|
|
240
|
+
content: z.string().optional().describe('Inline Turtle content to validate (alternative to path).'),
|
|
241
|
+
},
|
|
242
|
+
async ({ path: filePath, content }) => {
|
|
243
|
+
if (!filePath && !content) {
|
|
244
|
+
return toolError('Either "path" or "content" argument is required.');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const { store: shapesStore, shapeFiles } = getShapes();
|
|
249
|
+
|
|
250
|
+
if (content) {
|
|
251
|
+
const result = validateTurtle(content, shapesStore, shapeFiles, '<inline>');
|
|
252
|
+
return toolResponse(result);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Validate file or directory — with path containment check
|
|
256
|
+
const absPath = path.resolve(process.cwd(), filePath!);
|
|
257
|
+
if (!validatePathBoundary(absPath, process.cwd())) {
|
|
258
|
+
return toolError('Path is outside the allowed boundary. Paths must resolve within the current working directory.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const stat = await fs.stat(absPath).catch(() => null);
|
|
262
|
+
if (!stat) {
|
|
263
|
+
return toolError('Path not found. Check that the file or directory exists.');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (stat.isFile()) {
|
|
267
|
+
const result = validateFile(absPath, shapesStore, shapeFiles);
|
|
268
|
+
return toolResponse(result);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Directory validation
|
|
272
|
+
const ttlFiles = findTurtleFiles(absPath);
|
|
273
|
+
const results = ttlFiles.map((f) => validateFile(f, shapesStore, shapeFiles));
|
|
274
|
+
const allValid = results.every((r) => r.valid);
|
|
275
|
+
|
|
276
|
+
return toolResponse({
|
|
277
|
+
valid: allValid,
|
|
278
|
+
filesValidated: ttlFiles.length,
|
|
279
|
+
results: results.map((r) => ({
|
|
280
|
+
file: r.file,
|
|
281
|
+
valid: r.valid,
|
|
282
|
+
issues: r.results.length,
|
|
283
|
+
details: r.results,
|
|
284
|
+
})),
|
|
285
|
+
});
|
|
286
|
+
} catch (err: unknown) {
|
|
287
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
288
|
+
return toolError(`Validation failed: ${message}`);
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── cascade_convert ─────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function registerConvert(server: McpServer): void {
|
|
297
|
+
server.tool(
|
|
298
|
+
'cascade_convert',
|
|
299
|
+
'Convert between health data formats. Supports FHIR R4 JSON to Cascade Turtle/JSON-LD and vice versa.',
|
|
300
|
+
{
|
|
301
|
+
content: z.string().describe('The content to convert (FHIR JSON string or Cascade Turtle string).'),
|
|
302
|
+
from: z.enum(['fhir', 'cascade']).describe('Source format.'),
|
|
303
|
+
to: z.enum(['cascade', 'fhir']).describe('Target format.'),
|
|
304
|
+
format: z.enum(['turtle', 'jsonld']).optional().describe('Output serialization format when converting to Cascade. Default: turtle.'),
|
|
305
|
+
},
|
|
306
|
+
async ({ content: inputContent, from, to, format }) => {
|
|
307
|
+
try {
|
|
308
|
+
const outputTarget = to === 'cascade' ? (format ?? 'turtle') : to;
|
|
309
|
+
const outputSerialization = (format ?? 'turtle') as 'turtle' | 'jsonld';
|
|
310
|
+
|
|
311
|
+
const result = await convert(inputContent, from, outputTarget, outputSerialization);
|
|
312
|
+
|
|
313
|
+
if (!result.success) {
|
|
314
|
+
return toolError(result.errors.join('; '));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { content: [{ type: 'text' as const, text: result.output }] };
|
|
318
|
+
} catch (err: unknown) {
|
|
319
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
320
|
+
return toolError(`Conversion failed: ${message}`);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── cascade_write ───────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function registerWrite(server: McpServer): void {
|
|
329
|
+
server.tool(
|
|
330
|
+
'cascade_write',
|
|
331
|
+
'Write a health record to a Cascade Pod with AIGenerated provenance. The record is serialized as Turtle and appended to the appropriate file in the Pod.',
|
|
332
|
+
{
|
|
333
|
+
path: z.string().optional().describe('Path to the Pod directory. Uses CASCADE_POD_PATH if omitted.'),
|
|
334
|
+
dataType: z
|
|
335
|
+
.enum([
|
|
336
|
+
'medications', 'conditions', 'allergies', 'lab-results',
|
|
337
|
+
'immunizations', 'vital-signs', 'supplements',
|
|
338
|
+
])
|
|
339
|
+
.describe('Type of health record to write.'),
|
|
340
|
+
record: z.record(z.string(), z.unknown()).describe('JSON object with record fields (e.g., { "name": "Aspirin", "dose": "81 mg" }).'),
|
|
341
|
+
provenance: z.object({
|
|
342
|
+
agentId: z.string().optional().describe('Identifier of the AI agent writing the data.'),
|
|
343
|
+
reason: z.string().optional().describe('Reason for creating this record.'),
|
|
344
|
+
confidence: z.number().min(0).max(1).optional().describe('Agent confidence level (0.0-1.0).'),
|
|
345
|
+
sourceRecords: z.array(z.string()).optional().describe('URIs of source records used to derive this data.'),
|
|
346
|
+
}).optional().describe('Provenance metadata for the written record.'),
|
|
347
|
+
},
|
|
348
|
+
withPodHandler(async (absDir, args) => {
|
|
349
|
+
const dataType = args.dataType as string;
|
|
350
|
+
const record = args.record as Record<string, unknown>;
|
|
351
|
+
const provenance = args.provenance as { agentId?: string; reason?: string; confidence?: number; sourceRecords?: string[] } | undefined;
|
|
352
|
+
|
|
353
|
+
const typeInfo = DATA_TYPES[dataType];
|
|
354
|
+
if (!typeInfo) {
|
|
355
|
+
throw new Error(`Unknown data type: ${dataType}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const uuid = randomUUID();
|
|
359
|
+
const recordUri = `urn:uuid:${uuid}`;
|
|
360
|
+
const timestamp = new Date().toISOString();
|
|
361
|
+
|
|
362
|
+
const turtle = buildRecordTurtle(recordUri, dataType, typeInfo, record, provenance, timestamp);
|
|
363
|
+
|
|
364
|
+
const targetDir = path.join(absDir, typeInfo.directory);
|
|
365
|
+
const targetFile = path.join(targetDir, typeInfo.filename);
|
|
366
|
+
|
|
367
|
+
// Path containment: verify target stays within the Pod
|
|
368
|
+
if (!validatePathBoundary(targetFile, absDir)) {
|
|
369
|
+
throw new Error('Target file path is outside the Pod directory.');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
373
|
+
|
|
374
|
+
let fileExistsFlag = false;
|
|
375
|
+
try {
|
|
376
|
+
await fs.access(targetFile);
|
|
377
|
+
fileExistsFlag = true;
|
|
378
|
+
} catch {
|
|
379
|
+
// File doesn't exist
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (fileExistsFlag) {
|
|
383
|
+
await fs.appendFile(targetFile, '\n' + turtle, 'utf-8');
|
|
384
|
+
} else {
|
|
385
|
+
const prefixes = generatePrefixes();
|
|
386
|
+
await fs.writeFile(targetFile, prefixes + '\n' + turtle, 'utf-8');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await writeAuditEntry(
|
|
390
|
+
absDir,
|
|
391
|
+
createAuditEntry('write', [dataType], 1, provenance?.agentId),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
success: true,
|
|
396
|
+
recordUri,
|
|
397
|
+
file: `${typeInfo.directory}/${typeInfo.filename}`,
|
|
398
|
+
provenance: {
|
|
399
|
+
type: 'AIGenerated',
|
|
400
|
+
agentId: provenance?.agentId ?? 'unknown-agent',
|
|
401
|
+
timestamp,
|
|
402
|
+
reason: provenance?.reason,
|
|
403
|
+
confidence: provenance?.confidence,
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ─── cascade_capabilities ────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
function registerCapabilities(server: McpServer): void {
|
|
413
|
+
server.tool(
|
|
414
|
+
'cascade_capabilities',
|
|
415
|
+
'Describe all available Cascade Protocol MCP tools, their parameters, and usage examples. Use this as the entry point for discovering what the server can do.',
|
|
416
|
+
{},
|
|
417
|
+
async () => {
|
|
418
|
+
const capabilities = {
|
|
419
|
+
name: '@the-cascade-protocol/cli',
|
|
420
|
+
version: '0.2.0',
|
|
421
|
+
description: 'Cascade Protocol MCP Server — Local-first AI agent access to structured health data.',
|
|
422
|
+
protocol: 'https://cascadeprotocol.org',
|
|
423
|
+
securityModel: {
|
|
424
|
+
networkCalls: 'zero — all operations are local',
|
|
425
|
+
dataStorage: 'local filesystem only',
|
|
426
|
+
provenance: 'all agent-written data tagged with AIGenerated provenance',
|
|
427
|
+
auditLog: 'all operations logged to provenance/audit-log.ttl in the Pod',
|
|
428
|
+
},
|
|
429
|
+
tools: [
|
|
430
|
+
{
|
|
431
|
+
name: 'cascade_pod_read',
|
|
432
|
+
description: 'Read a Pod and return a JSON summary of all contents',
|
|
433
|
+
parameters: { path: 'string (optional) — Pod directory path' },
|
|
434
|
+
returns: 'JSON with patient profile, record counts, provenance sources',
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: 'cascade_pod_query',
|
|
438
|
+
description: 'Query records by data type',
|
|
439
|
+
parameters: {
|
|
440
|
+
path: 'string (optional) — Pod directory path',
|
|
441
|
+
dataType: 'medications|conditions|allergies|lab-results|immunizations|vital-signs|supplements|insurance|patient-profile|heart-rate|blood-pressure|activity|sleep|all',
|
|
442
|
+
},
|
|
443
|
+
returns: 'JSON array of matching records with properties',
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: 'cascade_validate',
|
|
447
|
+
description: 'Validate Turtle data against SHACL shapes',
|
|
448
|
+
parameters: {
|
|
449
|
+
path: 'string (optional) — file or directory path',
|
|
450
|
+
content: 'string (optional) — inline Turtle content',
|
|
451
|
+
},
|
|
452
|
+
returns: 'Validation results with pass/fail per constraint',
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
name: 'cascade_convert',
|
|
456
|
+
description: 'Convert between FHIR R4 JSON and Cascade Turtle/JSON-LD',
|
|
457
|
+
parameters: {
|
|
458
|
+
content: 'string — content to convert',
|
|
459
|
+
from: 'fhir|cascade',
|
|
460
|
+
to: 'cascade|fhir',
|
|
461
|
+
format: 'turtle|jsonld (optional, default: turtle)',
|
|
462
|
+
},
|
|
463
|
+
returns: 'Converted output',
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: 'cascade_write',
|
|
467
|
+
description: 'Write a health record to a Pod with AIGenerated provenance',
|
|
468
|
+
parameters: {
|
|
469
|
+
path: 'string (optional) — Pod directory path',
|
|
470
|
+
dataType: 'medications|conditions|allergies|lab-results|immunizations|vital-signs|supplements',
|
|
471
|
+
record: 'JSON object with record fields',
|
|
472
|
+
provenance: 'JSON object with agentId, reason, confidence, sourceRecords (all optional)',
|
|
473
|
+
},
|
|
474
|
+
returns: 'Record URI, file path, provenance metadata',
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: 'cascade_capabilities',
|
|
478
|
+
description: 'This tool — describes all available tools',
|
|
479
|
+
parameters: {},
|
|
480
|
+
returns: 'This capabilities document',
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
namespaces: {
|
|
484
|
+
cascade: 'https://ns.cascadeprotocol.org/core/v1#',
|
|
485
|
+
clinical: 'https://ns.cascadeprotocol.org/clinical/v1#',
|
|
486
|
+
health: 'https://ns.cascadeprotocol.org/health/v1#',
|
|
487
|
+
checkup: 'https://ns.cascadeprotocol.org/checkup/v1#',
|
|
488
|
+
pots: 'https://ns.cascadeprotocol.org/pots/v1#',
|
|
489
|
+
coverage: 'https://ns.cascadeprotocol.org/coverage/v1#',
|
|
490
|
+
},
|
|
491
|
+
provenanceTypes: [
|
|
492
|
+
'cascade:ClinicalGenerated — Data from clinical/EHR sources',
|
|
493
|
+
'cascade:DeviceGenerated — Data from wearable/medical devices',
|
|
494
|
+
'cascade:SelfReported — Patient-entered data',
|
|
495
|
+
'cascade:AIExtracted — AI-extracted from existing clinical documents',
|
|
496
|
+
'cascade:AIGenerated — AI-generated observations, analyses, or recommendations',
|
|
497
|
+
],
|
|
498
|
+
cliEquivalents: {
|
|
499
|
+
cascade_pod_read: 'cascade pod info <pod-dir> --json',
|
|
500
|
+
cascade_pod_query: 'cascade pod query <pod-dir> --medications --json',
|
|
501
|
+
cascade_validate: 'cascade validate <file-or-dir> --json',
|
|
502
|
+
cascade_convert: 'cascade convert --from fhir --to cascade <file>',
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
return { content: [{ type: 'text', text: JSON.stringify(capabilities, null, 2) }] };
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ─── Turtle Generation Helpers ───────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
/** Generate namespace prefixes for a new Turtle file. */
|
|
514
|
+
export function generatePrefixes(): string {
|
|
515
|
+
return `@prefix cascade: <https://ns.cascadeprotocol.org/core/v1#> .
|
|
516
|
+
@prefix health: <https://ns.cascadeprotocol.org/health/v1#> .
|
|
517
|
+
@prefix clinical: <https://ns.cascadeprotocol.org/clinical/v1#> .
|
|
518
|
+
@prefix coverage: <https://ns.cascadeprotocol.org/coverage/v1#> .
|
|
519
|
+
@prefix fhir: <http://hl7.org/fhir/> .
|
|
520
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
521
|
+
@prefix prov: <http://www.w3.org/ns/prov#> .
|
|
522
|
+
`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Map from data type key to rdf:type and name predicate. */
|
|
526
|
+
export const TYPE_MAPPING: Record<string, { rdfType: string; nameKey: string; namePred: string }> = {
|
|
527
|
+
medications: { rdfType: 'health:MedicationRecord', nameKey: 'name', namePred: 'health:medicationName' },
|
|
528
|
+
conditions: { rdfType: 'health:ConditionRecord', nameKey: 'name', namePred: 'health:conditionName' },
|
|
529
|
+
allergies: { rdfType: 'health:AllergyRecord', nameKey: 'name', namePred: 'health:allergen' },
|
|
530
|
+
'lab-results': { rdfType: 'health:LabResultRecord', nameKey: 'name', namePred: 'health:testName' },
|
|
531
|
+
immunizations: { rdfType: 'health:ImmunizationRecord', nameKey: 'name', namePred: 'health:vaccineName' },
|
|
532
|
+
'vital-signs': { rdfType: 'clinical:VitalSign', nameKey: 'type', namePred: 'clinical:vitalType' },
|
|
533
|
+
supplements: { rdfType: 'clinical:Supplement', nameKey: 'name', namePred: 'clinical:supplementName' },
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/** Property name mapping from JSON keys to Turtle predicates. */
|
|
537
|
+
export const PROPERTY_PREDICATES: Record<string, string> = {
|
|
538
|
+
dose: 'health:dose',
|
|
539
|
+
frequency: 'health:frequency',
|
|
540
|
+
route: 'health:route',
|
|
541
|
+
prescriber: 'health:prescriber',
|
|
542
|
+
startDate: 'health:startDate',
|
|
543
|
+
endDate: 'health:endDate',
|
|
544
|
+
isActive: 'health:isActive',
|
|
545
|
+
status: 'health:status',
|
|
546
|
+
onsetDate: 'health:onsetDate',
|
|
547
|
+
reaction: 'health:reaction',
|
|
548
|
+
severity: 'health:allergySeverity',
|
|
549
|
+
allergyCategory: 'health:allergyCategory',
|
|
550
|
+
resultValue: 'health:resultValue',
|
|
551
|
+
resultUnit: 'health:resultUnit',
|
|
552
|
+
referenceRange: 'health:referenceRange',
|
|
553
|
+
interpretation: 'health:interpretation',
|
|
554
|
+
performedDate: 'health:performedDate',
|
|
555
|
+
testCode: 'health:testCode',
|
|
556
|
+
vaccineDate: 'health:administrationDate',
|
|
557
|
+
administrationDate: 'health:administrationDate',
|
|
558
|
+
lotNumber: 'health:lotNumber',
|
|
559
|
+
site: 'health:site',
|
|
560
|
+
manufacturer: 'health:manufacturer',
|
|
561
|
+
vitalType: 'clinical:vitalType',
|
|
562
|
+
value: 'health:resultValue',
|
|
563
|
+
unit: 'health:resultUnit',
|
|
564
|
+
notes: 'health:notes',
|
|
565
|
+
indication: 'clinical:indication',
|
|
566
|
+
medicationClass: 'health:medicationClass',
|
|
567
|
+
conditionClass: 'health:conditionClass',
|
|
568
|
+
form: 'clinical:form',
|
|
569
|
+
evidenceStrength: 'clinical:evidenceStrength',
|
|
570
|
+
reasonForUse: 'clinical:reasonForUse',
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Build a Turtle serialization for a record.
|
|
575
|
+
*/
|
|
576
|
+
export function buildRecordTurtle(
|
|
577
|
+
recordUri: string,
|
|
578
|
+
dataType: string,
|
|
579
|
+
_typeInfo: typeof DATA_TYPES[string],
|
|
580
|
+
record: Record<string, unknown>,
|
|
581
|
+
provenance: { agentId?: string; reason?: string; confidence?: number; sourceRecords?: string[] } | undefined,
|
|
582
|
+
timestamp: string,
|
|
583
|
+
): string {
|
|
584
|
+
const typeMapping = TYPE_MAPPING[dataType];
|
|
585
|
+
if (!typeMapping) {
|
|
586
|
+
throw new Error(`No type mapping for data type: ${dataType}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const lines: string[] = [];
|
|
590
|
+
lines.push(`<${recordUri}> a ${typeMapping.rdfType} ;`);
|
|
591
|
+
|
|
592
|
+
// Add the name/label
|
|
593
|
+
const nameValue = record[typeMapping.nameKey] ?? record['name'];
|
|
594
|
+
if (nameValue) {
|
|
595
|
+
lines.push(` ${typeMapping.namePred} ${escapeTurtleString(String(nameValue))} ;`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Add mapped properties
|
|
599
|
+
for (const [key, value] of Object.entries(record)) {
|
|
600
|
+
if (key === typeMapping.nameKey || key === 'name') continue; // Already handled
|
|
601
|
+
const pred = PROPERTY_PREDICATES[key];
|
|
602
|
+
if (pred && value !== undefined && value !== null) {
|
|
603
|
+
lines.push(` ${pred} ${formatTurtleValue(key, value)} ;`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Add provenance — always AIGenerated for agent-written data
|
|
608
|
+
lines.push(` cascade:dataProvenance cascade:AIGenerated ;`);
|
|
609
|
+
lines.push(` cascade:schemaVersion "1.3" ;`);
|
|
610
|
+
|
|
611
|
+
// Add provenance metadata as blank node
|
|
612
|
+
const agentId = provenance?.agentId ?? 'unknown-agent';
|
|
613
|
+
const reason = provenance?.reason ?? 'Agent-generated record';
|
|
614
|
+
lines.push(` prov:wasGeneratedBy [`);
|
|
615
|
+
lines.push(` a prov:Activity, cascade:AIGenerated ;`);
|
|
616
|
+
lines.push(` prov:wasAssociatedWith "${agentId}" ;`);
|
|
617
|
+
lines.push(` prov:atTime "${timestamp}"^^xsd:dateTime ;`);
|
|
618
|
+
lines.push(` cascade:generationReason ${escapeTurtleString(reason)}`);
|
|
619
|
+
|
|
620
|
+
if (provenance?.confidence !== undefined) {
|
|
621
|
+
lines.push(` ; cascade:confidence "${provenance.confidence}"^^xsd:double`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (provenance?.sourceRecords && provenance.sourceRecords.length > 0) {
|
|
625
|
+
const sources = provenance.sourceRecords.map((s) => `<${s}>`).join(', ');
|
|
626
|
+
lines.push(` ; prov:used ${sources}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Close blank node and record
|
|
630
|
+
lines.push(` ] .`);
|
|
631
|
+
|
|
632
|
+
return lines.join('\n');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Escape a string for Turtle literal. Handles all standard escape sequences. */
|
|
636
|
+
export function escapeTurtleString(value: string): string {
|
|
637
|
+
const escaped = value
|
|
638
|
+
.replace(/\\/g, '\\\\')
|
|
639
|
+
.replace(/"/g, '\\"')
|
|
640
|
+
.replace(/\n/g, '\\n')
|
|
641
|
+
.replace(/\r/g, '\\r')
|
|
642
|
+
.replace(/\t/g, '\\t');
|
|
643
|
+
// Use triple-quoted long literal for very long strings or strings with embedded newlines
|
|
644
|
+
if (value.length > 200 || value.includes('\n')) {
|
|
645
|
+
const longEscaped = value
|
|
646
|
+
.replace(/\\/g, '\\\\')
|
|
647
|
+
.replace(/"""/g, '\\"\\"\\"');
|
|
648
|
+
return `"""${longEscaped}"""`;
|
|
649
|
+
}
|
|
650
|
+
return `"${escaped}"`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Format a value for Turtle based on key name / value type. */
|
|
654
|
+
export function formatTurtleValue(key: string, value: unknown): string {
|
|
655
|
+
if (typeof value === 'boolean') {
|
|
656
|
+
return `${value}`;
|
|
657
|
+
}
|
|
658
|
+
if (typeof value === 'number') {
|
|
659
|
+
return Number.isInteger(value) ? `${value}` : `"${value}"^^xsd:double`;
|
|
660
|
+
}
|
|
661
|
+
// Date-like keys get xsd:dateTime typing
|
|
662
|
+
if (key.toLowerCase().includes('date') || key.toLowerCase().includes('time')) {
|
|
663
|
+
return `"${String(value)}"^^xsd:dateTime`;
|
|
664
|
+
}
|
|
665
|
+
return escapeTurtleString(String(value));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// UUID generation uses crypto.randomUUID() — cryptographically secure, built into Node.js 14.17+.
|