@abraca/mcp 1.9.1 → 2.3.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.
@@ -111,6 +111,16 @@ export const PAGE_TYPES: Record<string, PageTypeInfo> = {
111
111
  core: true,
112
112
  supportsChildren: true,
113
113
  },
114
+ prose: {
115
+ key: 'prose',
116
+ label: 'Prose',
117
+ icon: 'pen-tool',
118
+ description: 'Long-form prose with serif typography and a narrow readable measure',
119
+ core: true,
120
+ supportsChildren: true,
121
+ childLabel: 'Item',
122
+ defaultDepth: -1,
123
+ },
114
124
  kanban: {
115
125
  key: 'kanban',
116
126
  label: 'Kanban',
@@ -1,355 +1,12 @@
1
- /**
2
- * Y.XmlFragment Markdown serializer.
3
- * Walks the TipTap document structure and produces markdown text.
4
- */
5
- import * as Y from 'yjs'
6
-
7
- // ── Inline text serialization ────────────────────────────────────────────────
8
-
9
- function deltaToMarkdown(delta: { insert: string; attributes?: Record<string, any> }[]): string {
10
- return delta.map(op => {
11
- let text = op.insert as string
12
- if (!op.attributes) return text
13
-
14
- const a = op.attributes
15
-
16
- if (a.code) text = `\`${text}\``
17
- if (a.bold) text = `**${text}**`
18
- if (a.italic) text = `*${text}*`
19
- if (a.strike) text = `~~${text}~~`
20
- if (a.link?.href) text = `[${text}](${a.link.href})`
21
- if (a.badge) text = `:badge[${a.badge.label || text}]`
22
- if (a.kbd) text = `:kbd{value="${a.kbd.value || text}"}`
23
- if (a.proseIcon) text = `:icon{name="${a.proseIcon.name}"}`
24
-
25
- return text
26
- }).join('')
27
- }
28
-
29
- function xmlTextToMarkdown(xmlText: Y.XmlText): string {
30
- const delta = xmlText.toDelta()
31
- return deltaToMarkdown(delta)
32
- }
33
-
34
- function elementTextContent(el: Y.XmlElement | Y.XmlFragment): string {
35
- const parts: string[] = []
36
- for (let i = 0; i < el.length; i++) {
37
- const child = el.get(i)
38
- if (child instanceof Y.XmlText) {
39
- parts.push(xmlTextToMarkdown(child))
40
- } else if (child instanceof Y.XmlElement) {
41
- // Inline nodes that carry meaning at the inline level.
42
- if (child.nodeName === 'docLink') {
43
- const docId = child.getAttribute('docId')
44
- if (docId) parts.push(`[[${docId}]]`)
45
- continue
46
- }
47
- parts.push(elementTextContent(child))
48
- }
49
- }
50
- return parts.join('')
51
- }
52
-
53
- // ── Block serialization ──────────────────────────────────────────────────────
54
-
55
- function serializeElement(el: Y.XmlElement, indent = ''): string {
56
- const name = el.nodeName
57
-
58
- switch (name) {
59
- case 'documentHeader':
60
- case 'documentMeta':
61
- return ''
62
-
63
- case 'heading': {
64
- const level = Number(el.getAttribute('level')) || 1
65
- const prefix = '#'.repeat(level)
66
- return `${prefix} ${elementTextContent(el)}`
67
- }
68
-
69
- case 'paragraph':
70
- return elementTextContent(el)
71
-
72
- case 'bulletList':
73
- return serializeList(el, 'bullet', indent)
74
-
75
- case 'orderedList':
76
- return serializeList(el, 'ordered', indent)
77
-
78
- case 'taskList':
79
- return serializeTaskList(el, indent)
80
-
81
- case 'codeBlock': {
82
- const lang = el.getAttribute('language') || ''
83
- const code = elementTextContent(el)
84
- return `\`\`\`${lang}\n${code}\n\`\`\``
85
- }
86
-
87
- case 'blockquote': {
88
- const lines: string[] = []
89
- for (let i = 0; i < el.length; i++) {
90
- const child = el.get(i)
91
- if (child instanceof Y.XmlElement) {
92
- lines.push(serializeElement(child, indent))
93
- }
94
- }
95
- return lines.map(l => `> ${l}`).join('\n')
96
- }
97
-
98
- case 'horizontalRule':
99
- return '---'
100
-
101
- case 'table':
102
- return serializeTable(el)
103
-
104
- case 'docEmbed': {
105
- const docId = el.getAttribute('docId')
106
- if (!docId) return ''
107
- const seamlessAttr = el.getAttribute('seamless')
108
- const seamless = seamlessAttr === true || seamlessAttr === 'true'
109
- return seamless ? `![[${docId}]]{seamless}` : `![[${docId}]]`
110
- }
111
-
112
- case 'svgEmbed': {
113
- const svg = el.getAttribute('svg') || ''
114
- const svgTitle = el.getAttribute('title') || ''
115
- if (!svg) return ''
116
- return `\`\`\`svg${svgTitle ? ` ${svgTitle}` : ''}\n${svg}\n\`\`\``
117
- }
118
-
119
- case 'image': {
120
- const src = el.getAttribute('src') || ''
121
- const alt = el.getAttribute('alt') || ''
122
- const w = el.getAttribute('width')
123
- const h = el.getAttribute('height')
124
- let attrs = ''
125
- if (w || h) {
126
- const parts: string[] = []
127
- if (w) parts.push(`width="${w}"`)
128
- if (h) parts.push(`height="${h}"`)
129
- attrs = `{${parts.join(' ')}}`
130
- }
131
- return `![${alt}](${src})${attrs}`
132
- }
133
-
134
- case 'callout': {
135
- const type = el.getAttribute('type') || 'note'
136
- const inner = serializeChildren(el, indent)
137
- return `::${type}\n${inner}\n::`
138
- }
139
-
140
- case 'collapsible': {
141
- const label = el.getAttribute('label') || 'Details'
142
- const open = el.getAttribute('open')
143
- const props: string[] = [`label="${label}"`]
144
- if (open === true || open === 'true') props.push('open="true"')
145
- const inner = serializeChildren(el, indent)
146
- return `::collapsible{${props.join(' ')}}\n${inner}\n::`
147
- }
148
-
149
- case 'steps': {
150
- const inner = serializeChildren(el, indent)
151
- return `::steps\n${inner}\n::`
152
- }
153
-
154
- case 'card': {
155
- const title = el.getAttribute('title') || ''
156
- const icon = el.getAttribute('icon') || ''
157
- const to = el.getAttribute('to') || ''
158
- const props: string[] = []
159
- if (title) props.push(`title="${title}"`)
160
- if (icon) props.push(`icon="${icon}"`)
161
- if (to) props.push(`to="${to}"`)
162
- const inner = serializeChildren(el, indent)
163
- return `::card{${props.join(' ')}}\n${inner}\n::`
164
- }
165
-
166
- case 'cardGroup': {
167
- const inner = serializeChildren(el, indent)
168
- return `::card-group\n${inner}\n::`
169
- }
170
-
171
- case 'codeCollapse': {
172
- const inner = serializeChildren(el, indent)
173
- return `::code-collapse\n${inner}\n::`
174
- }
175
-
176
- case 'codeGroup': {
177
- const inner = serializeChildren(el, indent)
178
- return `::code-group\n${inner}\n::`
179
- }
180
-
181
- case 'codePreview': {
182
- const inner = serializeChildren(el, indent)
183
- return `::code-preview\n${inner}\n::`
184
- }
185
-
186
- case 'codeTree': {
187
- const files = el.getAttribute('files') || '[]'
188
- return `::code-tree{files="${files}"}\n::`
189
- }
190
-
191
- case 'accordion':
192
- return serializeSlottedComponent(el, 'accordion', 'accordionItem', 'item')
193
-
194
- case 'tabs':
195
- return serializeSlottedComponent(el, 'tabs', 'tabsItem', 'tab')
196
-
197
- case 'field': {
198
- const fieldName = el.getAttribute('name') || ''
199
- const fieldType = el.getAttribute('type') || 'string'
200
- const required = el.getAttribute('required')
201
- const props: string[] = []
202
- if (fieldName) props.push(`name="${fieldName}"`)
203
- props.push(`type="${fieldType}"`)
204
- if (required === true || required === 'true') props.push('required="true"')
205
- const inner = serializeChildren(el, indent)
206
- return `::field{${props.join(' ')}}\n${inner}\n::`
207
- }
208
-
209
- case 'fieldGroup': {
210
- const inner = serializeChildren(el, indent)
211
- return `::field-group\n${inner}\n::`
212
- }
213
-
214
- default: {
215
- // Unknown node — try to serialize children
216
- return serializeChildren(el, indent)
217
- }
218
- }
219
- }
220
-
221
- function serializeList(el: Y.XmlElement, type: 'bullet' | 'ordered', indent: string): string {
222
- const lines: string[] = []
223
- for (let i = 0; i < el.length; i++) {
224
- const item = el.get(i)
225
- if (item instanceof Y.XmlElement && item.nodeName === 'listItem') {
226
- const prefix = type === 'bullet' ? '- ' : `${i + 1}. `
227
- const content = elementTextContent(item)
228
- lines.push(`${indent}${prefix}${content}`)
229
- }
230
- }
231
- return lines.join('\n')
232
- }
233
-
234
- function serializeTaskList(el: Y.XmlElement, indent: string): string {
235
- const lines: string[] = []
236
- for (let i = 0; i < el.length; i++) {
237
- const item = el.get(i)
238
- if (item instanceof Y.XmlElement && item.nodeName === 'taskItem') {
239
- const checked = item.getAttribute('checked')
240
- const marker = (checked === true || checked === 'true') ? '[x]' : '[ ]'
241
- const content = elementTextContent(item)
242
- lines.push(`${indent}- ${marker} ${content}`)
243
- }
244
- }
245
- return lines.join('\n')
246
- }
247
-
248
- function serializeTable(el: Y.XmlElement): string {
249
- const rows: string[][] = []
250
- let isHeader = true
251
-
252
- for (let i = 0; i < el.length; i++) {
253
- const row = el.get(i)
254
- if (!(row instanceof Y.XmlElement) || row.nodeName !== 'tableRow') continue
255
-
256
- const cells: string[] = []
257
- for (let j = 0; j < row.length; j++) {
258
- const cell = row.get(j)
259
- if (cell instanceof Y.XmlElement) {
260
- cells.push(elementTextContent(cell))
261
- }
262
- }
263
- rows.push(cells)
264
- }
265
-
266
- if (!rows.length) return ''
267
-
268
- const headerRow = rows[0]!
269
- const lines = [`| ${headerRow.join(' | ')} |`]
270
-
271
- // Separator
272
- lines.push(`| ${headerRow.map(() => '---').join(' | ')} |`)
273
-
274
- // Data rows
275
- for (let i = 1; i < rows.length; i++) {
276
- lines.push(`| ${rows[i]!.join(' | ')} |`)
277
- }
278
-
279
- return lines.join('\n')
280
- }
281
-
282
- function serializeSlottedComponent(
283
- el: Y.XmlElement,
284
- componentName: string,
285
- childNodeName: string,
286
- slotName: string
287
- ): string {
288
- const parts: string[] = [`::${componentName}`]
289
-
290
- for (let i = 0; i < el.length; i++) {
291
- const child = el.get(i)
292
- if (!(child instanceof Y.XmlElement) || child.nodeName !== childNodeName) continue
293
-
294
- const label = child.getAttribute('label') || `Item ${i + 1}`
295
- const icon = child.getAttribute('icon') || ''
296
- const props: string[] = [`label="${label}"`]
297
- if (icon) props.push(`icon="${icon}"`)
298
- parts.push(`#${slotName}{${props.join(' ')}}`)
299
-
300
- const inner = serializeChildren(child, '')
301
- if (inner) parts.push(inner)
302
- }
303
-
304
- parts.push('::')
305
- return parts.join('\n')
306
- }
307
-
308
- function serializeChildren(el: Y.XmlElement | Y.XmlFragment, indent: string): string {
309
- const parts: string[] = []
310
- for (let i = 0; i < el.length; i++) {
311
- const child = el.get(i)
312
- if (child instanceof Y.XmlElement) {
313
- const serialized = serializeElement(child, indent)
314
- if (serialized) parts.push(serialized)
315
- } else if (child instanceof Y.XmlText) {
316
- const text = xmlTextToMarkdown(child)
317
- if (text) parts.push(text)
318
- }
319
- }
320
- return parts.join('\n\n')
321
- }
322
-
323
- // ── Public API ───────────────────────────────────────────────────────────────
324
-
325
- /**
326
- * Converts a Y.XmlFragment (TipTap document) to markdown.
327
- * Extracts the title from the documentHeader element.
328
- *
329
- * @returns `{ title, markdown }` where title is the H1/header text
330
- */
331
- export function yjsToMarkdown(fragment: Y.XmlFragment): { title: string; markdown: string } {
332
- let title = 'Untitled'
333
- const bodyParts: string[] = []
334
-
335
- for (let i = 0; i < fragment.length; i++) {
336
- const child = fragment.get(i)
337
- if (!(child instanceof Y.XmlElement)) continue
338
-
339
- if (child.nodeName === 'documentHeader') {
340
- title = elementTextContent(child) || 'Untitled'
341
- continue
342
- }
343
-
344
- if (child.nodeName === 'documentMeta') {
345
- continue
346
- }
347
-
348
- const serialized = serializeElement(child)
349
- if (serialized !== '') {
350
- bodyParts.push(serialized)
351
- }
352
- }
353
-
354
- return { title, markdown: bodyParts.join('\n\n') }
355
- }
1
+ // Thin re-export shim — Yjs → markdown conversion now lives in
2
+ // @abraca/convert (extracted from cou-sh + abracadabra-nuxt + this
3
+ // package). Existing imports inside the MCP server keep working
4
+ // through this re-export.
5
+ //
6
+ // New code should import from @abraca/convert directly.
7
+
8
+ export {
9
+ yjsToMarkdown,
10
+ yjsToHtml,
11
+ yjsToPlainText,
12
+ } from '@abraca/convert'
package/src/crypto.ts CHANGED
@@ -2,11 +2,12 @@
2
2
  * Ed25519 key generation, persistence, and challenge signing for MCP agent auth.
3
3
  */
4
4
  import * as ed from '@noble/ed25519'
5
- import { sha512 } from '@noble/hashes/sha2'
5
+ import { sha512 } from '@noble/hashes/sha2.js'
6
6
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
7
7
 
8
- // @noble/ed25519 v2+ requires explicit hash configuration
9
- ed.etc.sha512Sync = (...msgs) => sha512(ed.etc.concatBytes(...msgs))
8
+ // @noble/ed25519 v3 hash hook
9
+ ed.hashes.sha512 = sha512
10
+ ed.hashes.sha512Async = (m: Uint8Array) => Promise.resolve(sha512(m))
10
11
  import { existsSync } from 'node:fs'
11
12
  import { homedir } from 'node:os'
12
13
  import { join, dirname } from 'node:path'
@@ -44,7 +45,7 @@ export async function loadOrCreateKeypair(keyPath?: string): Promise<AgentKeypai
44
45
  }
45
46
 
46
47
  // Generate new keypair
47
- const privateKey = ed.utils.randomPrivateKey()
48
+ const privateKey = ed.utils.randomSecretKey()
48
49
  const publicKey = ed.getPublicKey(privateKey)
49
50
 
50
51
  // Ensure directory exists and write seed with restricted permissions
package/src/index.ts CHANGED
@@ -16,6 +16,13 @@
16
16
  * DMs always trigger regardless of mode.
17
17
  * ABRA_AGENT_MENTION_ALIASES — Comma-separated aliases for @mentions
18
18
  * (default: [ABRA_AGENT_NAME])
19
+ * ABRA_MCP_SCHEMA_BUNDLE — (optional) Path to a JSON Schema bundle
20
+ * emitted by `@abraca/schema`. When set,
21
+ * update_metadata + create_document
22
+ * reject `meta` payloads that fail
23
+ * validation against the resolved doc-type's
24
+ * schema before forwarding to the server.
25
+ * When unset, behaviour is unchanged.
19
26
  */
20
27
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
21
28
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -32,6 +39,8 @@ import { registerTreeResource } from './resources/tree-resource.ts'
32
39
  import { registerServerInfoResource } from './resources/server-info.ts'
33
40
  import { registerHookTools } from './tools/hooks.ts'
34
41
  import { HookBridge } from './hook-bridge.ts'
42
+ import { loadSchemaBundle, SchemaBundleLoadError } from './schema/loader.ts'
43
+ import type { SchemaBundleValidator } from './schema/validator.ts'
35
44
 
36
45
  async function main() {
37
46
  const url = process.env.ABRA_URL
@@ -69,6 +78,24 @@ async function main() {
69
78
 
70
79
  console.error(`[abracadabra-mcp] Trigger mode: ${triggerMode}; aliases: ${server.mentionAliases.join(', ')}`)
71
80
 
81
+ // Optional schema bundle. When set, update_metadata + create_document validate
82
+ // `meta` against the resolved doc-type's schema before forwarding. When unset,
83
+ // behaviour is identical to today (Rules 2 + 4 in feedback_schema_free_core.md).
84
+ let schemaValidator: SchemaBundleValidator | null = null
85
+ const schemaBundlePath = process.env.ABRA_MCP_SCHEMA_BUNDLE
86
+ if (schemaBundlePath && schemaBundlePath.trim().length > 0) {
87
+ try {
88
+ schemaValidator = loadSchemaBundle(schemaBundlePath)
89
+ console.error(
90
+ `[abracadabra-mcp] Loaded schema bundle from ${schemaBundlePath}; known doc-types: ${schemaValidator.knownTypes.join(', ')}`,
91
+ )
92
+ } catch (err) {
93
+ const msg = err instanceof SchemaBundleLoadError ? err.message : String(err)
94
+ console.error(`[abracadabra-mcp] ${msg}`)
95
+ process.exit(1)
96
+ }
97
+ }
98
+
72
99
  // Create MCP server with channel capability
73
100
  const mcp = new McpServer(
74
101
  { name: 'abracadabra', version: '1.0.0' },
@@ -113,9 +140,9 @@ Read the resource at abracadabra://agent-guide for the complete guide covering p
113
140
  )
114
141
 
115
142
  // Register tools
116
- registerTreeTools(mcp, server)
143
+ registerTreeTools(mcp, server, schemaValidator)
117
144
  registerContentTools(mcp, server)
118
- registerMetaTools(mcp, server)
145
+ registerMetaTools(mcp, server, schemaValidator)
119
146
  registerFileTools(mcp, server)
120
147
  registerAwarenessTools(mcp, server)
121
148
  registerChannelTools(mcp, server)
@@ -21,7 +21,7 @@ export function registerServerInfoResource(mcp: McpServer, server: AbracadabraMC
21
21
  }
22
22
 
23
23
  const spaces = server.spaces
24
- const activeSpace = spaces.find(s => s.doc_id === server.rootDocId) ?? null
24
+ const activeSpace = spaces.find(s => s.id === server.rootDocId) ?? null
25
25
 
26
26
  const info = {
27
27
  serverInfo: server.serverInfo,
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Schema bundle loader for the MCP runtime hook.
3
+ *
4
+ * Reads a JSON Schema bundle produced by `@abraca/schema` codegen, compiles
5
+ * one Ajv validator per declared doc-type, and returns a
6
+ * {@link SchemaBundleValidator} that the MCP tool handlers consult before
7
+ * forwarding `meta` writes to the server.
8
+ *
9
+ * Bundle format (see abracadabra-ts/packages/schema/scripts/codegen-json-schema.ts):
10
+ *
11
+ * ```jsonc
12
+ * { "schemaVersion": 1,
13
+ * "label": "...",
14
+ * "types": {
15
+ * "<doc-type>": {
16
+ * "version": 1,
17
+ * "meta": { ...JSON Schema 2020-12... },
18
+ * "body": "ytext" | "yarray" | "ymap" | "none",
19
+ * "children": ["..."]
20
+ * } } }
21
+ * ```
22
+ *
23
+ * Loader errors at boot are *fatal* — caller is expected to abort startup
24
+ * with a clear message. We do not silently fall back to "no validation".
25
+ */
26
+
27
+ import { readFileSync } from 'node:fs'
28
+ import Ajv2020 from 'ajv/dist/2020.js'
29
+ import addFormats from 'ajv-formats'
30
+ import type { ValidateFunction } from 'ajv/dist/2020.js'
31
+ import type {
32
+ MetaValidationIssue,
33
+ MetaValidationResult,
34
+ SchemaBundleValidator,
35
+ } from './validator.ts'
36
+
37
+ interface BundleDoc {
38
+ version: number
39
+ meta: Record<string, unknown>
40
+ body: 'ytext' | 'yarray' | 'ymap' | 'none'
41
+ children: ReadonlyArray<string>
42
+ }
43
+
44
+ interface Bundle {
45
+ schemaVersion: number
46
+ label?: string
47
+ types: Record<string, BundleDoc>
48
+ }
49
+
50
+ const SUPPORTED_BUNDLE_VERSION = 1
51
+
52
+ export class SchemaBundleLoadError extends Error {
53
+ constructor(message: string, readonly cause?: unknown) {
54
+ super(message)
55
+ this.name = 'SchemaBundleLoadError'
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Read, parse, and compile a JSON Schema bundle into a runtime validator.
61
+ *
62
+ * @throws {SchemaBundleLoadError} if the file is missing, malformed, has
63
+ * an unsupported `schemaVersion`, or any per-type schema fails to compile.
64
+ */
65
+ export function loadSchemaBundle(path: string): SchemaBundleValidator {
66
+ let raw: string
67
+ try {
68
+ raw = readFileSync(path, 'utf8')
69
+ } catch (err) {
70
+ throw new SchemaBundleLoadError(
71
+ `Failed to read schema bundle from ${path}: ${(err as Error).message}`,
72
+ err,
73
+ )
74
+ }
75
+
76
+ let bundle: Bundle
77
+ try {
78
+ bundle = JSON.parse(raw) as Bundle
79
+ } catch (err) {
80
+ throw new SchemaBundleLoadError(
81
+ `Schema bundle at ${path} is not valid JSON: ${(err as Error).message}`,
82
+ err,
83
+ )
84
+ }
85
+
86
+ if (bundle.schemaVersion !== SUPPORTED_BUNDLE_VERSION) {
87
+ throw new SchemaBundleLoadError(
88
+ `Schema bundle at ${path} has schemaVersion ${bundle.schemaVersion}; this MCP server supports ${SUPPORTED_BUNDLE_VERSION}`,
89
+ )
90
+ }
91
+ if (!bundle.types || typeof bundle.types !== 'object') {
92
+ throw new SchemaBundleLoadError(
93
+ `Schema bundle at ${path} is missing the "types" object`,
94
+ )
95
+ }
96
+
97
+ const ajv = new Ajv2020({
98
+ strict: false,
99
+ allErrors: true,
100
+ // JSON Schema bundles include their own $schema URI; we don't need Ajv to add another.
101
+ validateSchema: false,
102
+ })
103
+ addFormats(ajv)
104
+
105
+ const validators = new Map<string, ValidateFunction>()
106
+ for (const [docType, decl] of Object.entries(bundle.types)) {
107
+ try {
108
+ const fn = ajv.compile(decl.meta)
109
+ validators.set(docType, fn)
110
+ } catch (err) {
111
+ throw new SchemaBundleLoadError(
112
+ `Schema bundle at ${path} has an invalid meta schema for "${docType}": ${(err as Error).message}`,
113
+ err,
114
+ )
115
+ }
116
+ }
117
+
118
+ const knownTypes = Array.from(validators.keys()).sort()
119
+
120
+ return {
121
+ knownTypes,
122
+ validateMeta(docType: string, meta: unknown): MetaValidationResult {
123
+ const fn = validators.get(docType)
124
+ if (!fn) {
125
+ // Unknown doc-type → unconstrained (Rule 4). Existing-app traffic
126
+ // for non-canary types is never rejected by this validator.
127
+ return { ok: true }
128
+ }
129
+ const ok = fn(meta)
130
+ if (ok) return { ok: true }
131
+ const errors: MetaValidationIssue[] = (fn.errors ?? []).map((e) => ({
132
+ path: e.instancePath || '/',
133
+ message: e.message ?? 'validation failed',
134
+ keyword: e.keyword,
135
+ }))
136
+ return { ok: false, errors }
137
+ },
138
+ }
139
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Validator types for the MCP schema runtime hook.
3
+ *
4
+ * The MCP server stays schema-agnostic at the type level: it loads
5
+ * JSON Schema bundles (data) at boot, never imports a live Zod registry.
6
+ * This file declares only the shape of the validator, not how it's built.
7
+ */
8
+
9
+ export interface MetaValidationIssue {
10
+ /** JSON-pointer-ish path to the offending field, e.g. "/priority". */
11
+ readonly path: string;
12
+ /** Human-readable error message. */
13
+ readonly message: string;
14
+ /** Ajv keyword that failed (e.g. "enum", "required", "pattern"). */
15
+ readonly keyword?: string;
16
+ }
17
+
18
+ export type MetaValidationResult =
19
+ | { ok: true }
20
+ | { ok: false; errors: ReadonlyArray<MetaValidationIssue> };
21
+
22
+ export interface SchemaBundleValidator {
23
+ /** Doc-types known to this bundle. */
24
+ readonly knownTypes: ReadonlyArray<string>;
25
+ /**
26
+ * Validate `meta` against the schema for `docType`. If `docType` is not
27
+ * in this bundle, returns `{ ok: true }` — unknown doc-types are
28
+ * unconstrained (Rule 4: never reject existing-app traffic).
29
+ */
30
+ validateMeta(docType: string, meta: unknown): MetaValidationResult;
31
+ }