@hames-ai/connectors 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/app-tools/registry.ts +185 -0
- package/graph/graph-auth.ts +29 -0
- package/graph/graph-tools.server.ts +1921 -0
- package/index.ts +40 -0
- package/mcp-catalog.ts +149 -0
- package/neo4j/client.ts +112 -0
- package/neo4j/graph-edit.server.ts +127 -0
- package/neo4j/index.ts +34 -0
- package/neo4j/plain.ts +153 -0
- package/neo4j/queries.ts +369 -0
- package/neo4j/transform.ts +437 -0
- package/package.json +66 -0
package/neo4j/queries.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neo4j Query Ops (Non-Agentic Layer) — identity-free by construction.
|
|
3
|
+
*
|
|
4
|
+
* Server-side functions for direct Neo4j operations:
|
|
5
|
+
* - Schema fetching for agent initialization
|
|
6
|
+
* - Manual Cypher queries from a graph visualization
|
|
7
|
+
* - Connection management
|
|
8
|
+
*
|
|
9
|
+
* These operations bypass the agent layer for performance and simplicity.
|
|
10
|
+
*
|
|
11
|
+
* ## Identity is a gate the HOST applies, not a dependency here
|
|
12
|
+
* The host keeps thin `'use server'` RPC modules at the paths its clients
|
|
13
|
+
* import; every one of those runs its own per-module auth gate (duplicated,
|
|
14
|
+
* never imported — SD-13) and then delegates here. These ops therefore carry
|
|
15
|
+
* no identity at all, and every session is opened in READ access mode: this
|
|
16
|
+
* module never writes, and the driver enforces that rather than the query
|
|
17
|
+
* text being inspected for it. The intent-shaped writes live in
|
|
18
|
+
* `graph-edit.server.ts`.
|
|
19
|
+
*
|
|
20
|
+
* The `'use server'` directive this module carried in the host app is
|
|
21
|
+
* stripped by the package move (#225 PR-C2); `assertServerOnImport()` is the
|
|
22
|
+
* load-time guard that replaces it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { assertServerOnImport } from '@hames-ai/harness-patterns/assert.server'
|
|
26
|
+
import neo4j from 'neo4j-driver'
|
|
27
|
+
import { getNeo4jDriver, resetDriver, verifyConnection } from './client'
|
|
28
|
+
import { transformNeo4jToCytoscape, parseNeo4jResults } from './transform'
|
|
29
|
+
import { toPlainNeo4jValue } from './plain'
|
|
30
|
+
|
|
31
|
+
assertServerOnImport()
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Types
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
export interface SchemaResult {
|
|
38
|
+
success: boolean
|
|
39
|
+
schema?: string
|
|
40
|
+
error?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CypherResult {
|
|
44
|
+
success: boolean
|
|
45
|
+
graphUpdate?: ReturnType<typeof transformNeo4jToCytoscape>
|
|
46
|
+
raw?: unknown[]
|
|
47
|
+
error?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConnectionResult {
|
|
51
|
+
success: boolean
|
|
52
|
+
error?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Guards
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A session the driver refuses to write through.
|
|
61
|
+
*
|
|
62
|
+
* Nothing in this module writes, so READ access mode costs nothing and makes
|
|
63
|
+
* the guarantee structural: the server rejects a write statement sent over a
|
|
64
|
+
* READ-mode transaction whatever the text says, which is the barrier a
|
|
65
|
+
* keyword blacklist can never be (comments, unicode escapes, `CALL { … }`
|
|
66
|
+
* subqueries, apoc procedures).
|
|
67
|
+
*/
|
|
68
|
+
function readSession() {
|
|
69
|
+
return getNeo4jDriver().session({ defaultAccessMode: neo4j.session.READ })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Schema Operations
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fetch the Neo4j database schema
|
|
78
|
+
* Used by agent for context about available node types and relationships
|
|
79
|
+
*/
|
|
80
|
+
export async function getSchema(): Promise<SchemaResult> {
|
|
81
|
+
const session = readSession()
|
|
82
|
+
try {
|
|
83
|
+
const result = await session.run('CALL db.schema.visualization()')
|
|
84
|
+
return {
|
|
85
|
+
success: true,
|
|
86
|
+
schema: JSON.stringify(result.records, null, 2),
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Failed to fetch schema:', error)
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: error instanceof Error ? error.message : String(error),
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
await session.close()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get a formatted schema for the BAML agent
|
|
101
|
+
* Produces concise, LLM-friendly output with:
|
|
102
|
+
* - Node labels with their properties
|
|
103
|
+
* - Relationship patterns (start)-[TYPE]->(end)
|
|
104
|
+
*/
|
|
105
|
+
export async function getSchemaForAgent(): Promise<SchemaResult> {
|
|
106
|
+
const session = readSession()
|
|
107
|
+
try {
|
|
108
|
+
// Get labels with their actual properties (sample 1 node per label)
|
|
109
|
+
const labelsQuery = `
|
|
110
|
+
CALL db.labels() YIELD label
|
|
111
|
+
CALL {
|
|
112
|
+
WITH label
|
|
113
|
+
MATCH (n) WHERE label IN labels(n)
|
|
114
|
+
RETURN keys(n) as props LIMIT 1
|
|
115
|
+
}
|
|
116
|
+
RETURN label, props
|
|
117
|
+
`
|
|
118
|
+
const labelsResult = await session.run(labelsQuery)
|
|
119
|
+
|
|
120
|
+
// Get relationship patterns by querying actual data
|
|
121
|
+
// (db.schema.visualization returns virtual nodes that don't support startNode/endNode)
|
|
122
|
+
const relsQuery = `
|
|
123
|
+
MATCH (a)-[r]->(b)
|
|
124
|
+
WITH
|
|
125
|
+
[lbl IN labels(a) WHERE lbl <> 'UNIQUE IMPORT LABEL'][0] as startLabel,
|
|
126
|
+
type(r) as relType,
|
|
127
|
+
[lbl IN labels(b) WHERE lbl <> 'UNIQUE IMPORT LABEL'][0] as endLabel
|
|
128
|
+
WHERE startLabel IS NOT NULL AND endLabel IS NOT NULL
|
|
129
|
+
RETURN DISTINCT startLabel, relType, endLabel
|
|
130
|
+
`
|
|
131
|
+
const relsResult = await session.run(relsQuery)
|
|
132
|
+
|
|
133
|
+
// Format as readable text
|
|
134
|
+
let schema = 'Node Labels:\n'
|
|
135
|
+
for (const record of labelsResult.records) {
|
|
136
|
+
const label = record.get('label')
|
|
137
|
+
if (label === 'UNIQUE IMPORT LABEL') continue // Skip APOC import label
|
|
138
|
+
const props = record.get('props') || []
|
|
139
|
+
schema += `- ${label} (properties: ${props.join(', ')})\n`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
schema += '\nRelationships:\n'
|
|
143
|
+
for (const record of relsResult.records) {
|
|
144
|
+
const start = record.get('startLabel')
|
|
145
|
+
const rel = record.get('relType')
|
|
146
|
+
const end = record.get('endLabel')
|
|
147
|
+
if (start && rel && end) {
|
|
148
|
+
schema += `- (${start})-[${rel}]->(${end})\n`
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { success: true, schema }
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('Failed to fetch agent schema:', error)
|
|
155
|
+
// Fallback to simplified schema
|
|
156
|
+
return getSimplifiedSchema()
|
|
157
|
+
} finally {
|
|
158
|
+
await session.close()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get a simplified schema representation
|
|
164
|
+
* Useful for smaller context windows
|
|
165
|
+
*/
|
|
166
|
+
export async function getSimplifiedSchema(): Promise<SchemaResult> {
|
|
167
|
+
const session = readSession()
|
|
168
|
+
try {
|
|
169
|
+
// Get node labels
|
|
170
|
+
const labelsResult = await session.run('CALL db.labels()')
|
|
171
|
+
const labels = labelsResult.records.map((r) => r.get(0))
|
|
172
|
+
|
|
173
|
+
// Get relationship types
|
|
174
|
+
const relTypesResult = await session.run('CALL db.relationshipTypes()')
|
|
175
|
+
const relTypes = relTypesResult.records.map((r) => r.get(0))
|
|
176
|
+
|
|
177
|
+
// Get property keys
|
|
178
|
+
const propsResult = await session.run('CALL db.propertyKeys()')
|
|
179
|
+
const propKeys = propsResult.records.map((r) => r.get(0))
|
|
180
|
+
|
|
181
|
+
const schema = {
|
|
182
|
+
nodeLabels: labels,
|
|
183
|
+
relationshipTypes: relTypes,
|
|
184
|
+
propertyKeys: propKeys,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
schema: JSON.stringify(schema, null, 2),
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('Failed to fetch simplified schema:', error)
|
|
193
|
+
return {
|
|
194
|
+
success: false,
|
|
195
|
+
error: error instanceof Error ? error.message : String(error),
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
await session.close()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// Node Property Operations
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
export interface NodePropertiesResult {
|
|
207
|
+
success: boolean
|
|
208
|
+
properties?: Record<string, unknown>
|
|
209
|
+
labels?: string[]
|
|
210
|
+
error?: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetch properties for a specific node by element ID
|
|
215
|
+
* Used when clicking on a graph node that doesn't have properties loaded
|
|
216
|
+
*
|
|
217
|
+
* @param elementId - Neo4j 5.x element ID (e.g., "4:xxx:123")
|
|
218
|
+
*/
|
|
219
|
+
export async function getNodeProperties(elementId: string): Promise<NodePropertiesResult> {
|
|
220
|
+
const session = readSession()
|
|
221
|
+
try {
|
|
222
|
+
const result = await session.run(
|
|
223
|
+
'MATCH (n) WHERE elementId(n) = $elementId RETURN properties(n) as props, labels(n) as labels',
|
|
224
|
+
{ elementId },
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if (result.records.length === 0) {
|
|
228
|
+
return { success: false, error: 'Node not found' }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const record = result.records[0]
|
|
232
|
+
return {
|
|
233
|
+
success: true,
|
|
234
|
+
// Plain projection, not the driver's own values: an int property is an
|
|
235
|
+
// `Integer` instance, which the RPC serializer cannot encode (see
|
|
236
|
+
// `plain.ts`).
|
|
237
|
+
properties: toPlainNeo4jValue(record.get('props')) as Record<string, unknown>,
|
|
238
|
+
labels: record.get('labels') as string[],
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.error('Failed to fetch node properties:', error)
|
|
242
|
+
return {
|
|
243
|
+
success: false,
|
|
244
|
+
error: error instanceof Error ? error.message : String(error),
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
await session.close()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// Manual Cypher Operations
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
// Defense in depth only (#230). The barrier is the READ-mode transaction
|
|
256
|
+
// below; this pre-check exists to answer an obvious write faster, and with a
|
|
257
|
+
// message that points at the host's chat interface. Word boundaries, not
|
|
258
|
+
// substrings (#190): `RETURN n.createdAt` and `MATCH (n:Dataset)` are reads.
|
|
259
|
+
const WRITE_CLAUSE = /\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b/i
|
|
260
|
+
|
|
261
|
+
// How Neo4j phrases its own refusal of a write over a READ transaction.
|
|
262
|
+
const DRIVER_READ_ONLY_REFUSAL = /read[- ]?only|read access mode|ForbiddenDueToTransactionType/i
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Execute a read-only Cypher query (for a graph visualization's manual input)
|
|
266
|
+
*
|
|
267
|
+
* The caller supplies the query text, so read-only is enforced by the driver
|
|
268
|
+
* (`executeRead` over a READ-mode session, #230), not by inspecting the text.
|
|
269
|
+
*
|
|
270
|
+
* @param cypher - The Cypher query to execute
|
|
271
|
+
*/
|
|
272
|
+
export async function runManualCypher(cypher: string): Promise<CypherResult> {
|
|
273
|
+
const writeClause = WRITE_CLAUSE.exec(cypher)
|
|
274
|
+
if (writeClause) {
|
|
275
|
+
return {
|
|
276
|
+
success: false,
|
|
277
|
+
error: `Manual queries cannot use write operations (${writeClause[1].toUpperCase()}). Use the chat interface for modifications.`,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const session = readSession()
|
|
282
|
+
try {
|
|
283
|
+
// READ mode is pinned on the transaction as well as the session, so a
|
|
284
|
+
// write that slipped past WRITE_CLAUSE is refused by the server.
|
|
285
|
+
const result = await session.executeRead((tx) => tx.run(cypher))
|
|
286
|
+
|
|
287
|
+
// Plain-project the rows *before* anything else touches them: both what
|
|
288
|
+
// goes back over the RPC and what the Cytoscape projection embeds
|
|
289
|
+
// (`data.properties`, `data.neo4jId`) would otherwise carry driver class
|
|
290
|
+
// instances, which the serializer refuses mid-stream (see `plain.ts`).
|
|
291
|
+
const rows = result.records.map(
|
|
292
|
+
(r) => toPlainNeo4jValue(r.toObject()) as Record<string, unknown>,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
// Parse and transform results for Cytoscape
|
|
296
|
+
const parsed = parseNeo4jResults({ records: rows })
|
|
297
|
+
const graphData = transformNeo4jToCytoscape(parsed.nodes || [], parsed.relationships || [])
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
success: true,
|
|
301
|
+
graphUpdate: graphData,
|
|
302
|
+
raw: rows,
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
console.error('Manual Cypher query failed:', error)
|
|
306
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
307
|
+
return {
|
|
308
|
+
success: false,
|
|
309
|
+
error: DRIVER_READ_ONLY_REFUSAL.test(message)
|
|
310
|
+
? `Manual queries are read-only and the database refused this one. Use the chat interface for modifications. (${message})`
|
|
311
|
+
: message,
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
await session.close()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// There is deliberately no write counterpart to `runManualCypher` (#228).
|
|
319
|
+
// `executeWriteCypher(cypher)` used to live where this module did in the host
|
|
320
|
+
// app: a `'use server'` RPC — so browser-reachable — that ran any string the
|
|
321
|
+
// caller sent, with no auth and no approval flow behind it despite what its
|
|
322
|
+
// comment claimed. It had no callers. Graph writes go through the
|
|
323
|
+
// intent-shaped ops in `graph-edit.server.ts`, which own their Cypher;
|
|
324
|
+
// nothing new belongs here that takes query text from a client.
|
|
325
|
+
//
|
|
326
|
+
// `runManualCypher` stays because a manual-query box in the host's graph
|
|
327
|
+
// visualization genuinely uses it, and it is safe on a different footing
|
|
328
|
+
// (#230): the caller must be authenticated (the host wrapper's gate), and
|
|
329
|
+
// the driver — not a blacklist — is what makes the query read-only. Any new
|
|
330
|
+
// export in this file must open its session through `readSession()`; that is
|
|
331
|
+
// what the source-scan pin in this package's queries tests holds.
|
|
332
|
+
|
|
333
|
+
// ============================================================================
|
|
334
|
+
// Connection Management
|
|
335
|
+
// ============================================================================
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Reset the Neo4j connection
|
|
339
|
+
* Forces the driver singleton to reconnect on the next query
|
|
340
|
+
*/
|
|
341
|
+
export async function resetNeo4jConnection(): Promise<ConnectionResult> {
|
|
342
|
+
try {
|
|
343
|
+
await resetDriver()
|
|
344
|
+
return { success: true }
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return {
|
|
347
|
+
success: false,
|
|
348
|
+
error: error instanceof Error ? error.message : String(error),
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Test the Neo4j connection
|
|
355
|
+
*/
|
|
356
|
+
export async function testNeo4jConnection(): Promise<ConnectionResult> {
|
|
357
|
+
try {
|
|
358
|
+
const connected = await verifyConnection()
|
|
359
|
+
return {
|
|
360
|
+
success: connected,
|
|
361
|
+
error: connected ? undefined : 'Connection verification failed',
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return {
|
|
365
|
+
success: false,
|
|
366
|
+
error: error instanceof Error ? error.message : String(error),
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|