@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Accetto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @hames-ai/connectors
|
|
2
|
+
|
|
3
|
+
The **connectors** companion package for
|
|
4
|
+
[`@hames-ai/harness-patterns`](../harness-patterns/README.md): the Microsoft Graph app-side
|
|
5
|
+
tools, the Neo4j non-agentic layer, and the MCP-gateway namespace catalog —
|
|
6
|
+
moved out of the host app (#225 PR-3) behind injected seams. The package owns
|
|
7
|
+
protocols, query shapes and schemas; the **host owns identity, tokens, content
|
|
8
|
+
classification and storage**, every one of them injected.
|
|
9
|
+
|
|
10
|
+
## Surface
|
|
11
|
+
|
|
12
|
+
| Subpath | What lives there |
|
|
13
|
+
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
14
|
+
| `.` (root barrel) | client-safe surface only: `mcpNamespace` / `MCP_TOOL_CATALOG`, the Neo4j→Cytoscape `transformNeo4jToCytoscape` / `parseNeo4jResults` |
|
|
15
|
+
| `./neo4j/client` | `configureNeo4j({ url, user, password })` + the driver singleton — **explicit-config-only**: unset config is a named error at first use, never an env fallback |
|
|
16
|
+
| `./neo4j/queries` | the identity-free read ops (`getSchema`, `runManualCypher`, …) — every session READ-mode (SD-14) |
|
|
17
|
+
| `./neo4j/graph-edit.server` | the intent-shaped write ops (`createGraphNode`, `linkGraphNodes`, `setGraphNodeProperty`) |
|
|
18
|
+
| `./neo4j/plain`, `./neo4j/transform` | plain projections of driver values; the Cytoscape projection |
|
|
19
|
+
| `./app-tools/registry` | `createAppToolRegistry({ resolveContext })` — the generic in-process tool registry |
|
|
20
|
+
| `./mcp-catalog` | this deployment's tool→namespace catalog (pure data) |
|
|
21
|
+
| `./graph/graph-tools.server` | `registerGraphConnectorTools(deps)` — the nine Microsoft Graph tools |
|
|
22
|
+
| `./graph/graph-auth` | `GraphAuthRequiredError`, owned by the package so `instanceof` survives across the seam |
|
|
23
|
+
|
|
24
|
+
## What is injected vs imported vs composed
|
|
25
|
+
|
|
26
|
+
**Injected (host → package, every field REQUIRED — a missing or non-function
|
|
27
|
+
supplier throws at factory call, never degrades):**
|
|
28
|
+
|
|
29
|
+
- `createAppToolRegistry({ resolveContext })`: the `userId` / `sessionId` pair
|
|
30
|
+
(the app's `getRequestUserId`/`getRequestSessionId`).
|
|
31
|
+
- `registerGraphConnectorTools(deps)`:
|
|
32
|
+
- `registerAppTool` — where the tools register;
|
|
33
|
+
- `graphFetch` (S1) — delegated-token Microsoft Graph fetch; the package
|
|
34
|
+
never sees a token;
|
|
35
|
+
- `content` (S4) — `conversionEnabled` / `isConvertible` / `guessMimeType` /
|
|
36
|
+
`isTextMime`, one required supplier;
|
|
37
|
+
- `stash` — `loadStore` / `ingest`: the Data Stash bridge the file-ingest
|
|
38
|
+
tool needs (lazily resolved by the host, so composing the tools never
|
|
39
|
+
loads the storage stack).
|
|
40
|
+
|
|
41
|
+
**Imported directly:** `@hames-ai/harness-patterns` (types, `assert.server`,
|
|
42
|
+
`tools.server`'s `ToolsFrom` in tests) and `neo4j-driver`. Nothing else —
|
|
43
|
+
there are no `app/src` imports, type-only included (pinned by the host's
|
|
44
|
+
`zero-app-imports.test.ts`).
|
|
45
|
+
|
|
46
|
+
**Composed host-side (not this package's business):** the `'use server'` RPC
|
|
47
|
+
wrappers with their per-module auth gates (SD-13: duplicated per module, never
|
|
48
|
+
imported), the transport registration on core's seam, the token/MSAL layer,
|
|
49
|
+
and the doc-convert/stash modules behind the content seam. A back-edge from
|
|
50
|
+
this package into the host's stash (`guessMimeType`/`isTextMime`) is
|
|
51
|
+
forbidden by design — see the S4 note in the host's composition root.
|
|
52
|
+
|
|
53
|
+
## No build step
|
|
54
|
+
|
|
55
|
+
Like every `@hames-ai` package, this one **ships TypeScript source**: `main` and
|
|
56
|
+
every code target in `exports` is a `.ts` file (`./package.json` is the one
|
|
57
|
+
non-code entry), there is no `dist/`, and `pnpm pack` is the whole publish
|
|
58
|
+
pipeline. Consumers are **TS-bundler consumers** — a project whose bundler or
|
|
59
|
+
runtime compiles TypeScript: Vite/vinxi, esbuild, tsx, Bun. **Not**
|
|
60
|
+
`node --experimental-strip-types`, which refuses to strip types under
|
|
61
|
+
`node_modules` — exactly where an installed package lives
|
|
62
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, measured on Node v22.21.1). A
|
|
63
|
+
plain `node dist/index.js` consumer is not supported either, deliberately: a
|
|
64
|
+
build step would make the published artefact different from the source every
|
|
65
|
+
test in this repo runs against.
|
|
66
|
+
|
|
67
|
+
## Tests
|
|
68
|
+
|
|
69
|
+
The suite is co-located under `__tests__/` and excluded from the published
|
|
70
|
+
tarball via the `files` allowlist. Run it with `pnpm test` from
|
|
71
|
+
`packages/connectors/`. It runs in a plain node environment and imports no
|
|
72
|
+
host-app code; tests that need the app's request scope or its composition
|
|
73
|
+
root stayed in the app's `src/__tests__/` tree.
|
|
74
|
+
|
|
75
|
+
The package publishes to npm as `@hames-ai/connectors` with `publishConfig.access:
|
|
76
|
+
public` (set at first publication; the setting travels with every future
|
|
77
|
+
version).
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-side tool registry — Server Only.
|
|
3
|
+
*
|
|
4
|
+
* A third tool transport alongside the MCP gateway and the sandbox: tools that
|
|
5
|
+
* execute **in this process** so a credential can be resolved server-side from
|
|
6
|
+
* the authenticated user and never leave it.
|
|
7
|
+
*
|
|
8
|
+
* ## Why these can't be gateway tools
|
|
9
|
+
* The MCP gateway is a single shared-identity credential boundary (#107): every
|
|
10
|
+
* user's calls run as one principal, and the static-secret model can't inject
|
|
11
|
+
* per-user credentials. Per-user Microsoft Graph access (#110) therefore cannot
|
|
12
|
+
* go through it without giving up delegated per-user scope — the entire point.
|
|
13
|
+
*
|
|
14
|
+
* ## Identity is injected, not imported (design S3, #225 PR-3)
|
|
15
|
+
* The registry is a generic in-process registry: it resolves the caller's
|
|
16
|
+
* identity through the `resolveContext` supplier the composition root hands to
|
|
17
|
+
* `createAppToolRegistry()`, so this module carries **no host imports** —
|
|
18
|
+
* this IS the `@hames-ai/connectors` module the PR-C1 peel prepared (moved
|
|
19
|
+
* #225 PR-C2). A missing supplier throws at factory call rather than
|
|
20
|
+
* degrading (PR-2 doctrine) — a registry that silently guessed or defaulted
|
|
21
|
+
* an identity would be the one failure this module exists to prevent.
|
|
22
|
+
*
|
|
23
|
+
* ## Invariants
|
|
24
|
+
* - The schema advertised to the model has **no credential field** (#107
|
|
25
|
+
* principle 1). The user id comes from `resolveContext` at call time, so the
|
|
26
|
+
* model cannot choose whose data to read.
|
|
27
|
+
* - Executors receive `{ userId, sessionId }` and resolve tokens themselves; a
|
|
28
|
+
* token must never appear in args, results, logs or the event stream.
|
|
29
|
+
* - Errors become `{ success: false, error }` rather than throwing, so one
|
|
30
|
+
* failing tool degrades a turn instead of killing a run.
|
|
31
|
+
*/
|
|
32
|
+
import { assertServerOnImport } from '@hames-ai/harness-patterns/assert.server'
|
|
33
|
+
import type { ToolCallResult, MCPToolDescription } from '@hames-ai/harness-patterns/types'
|
|
34
|
+
|
|
35
|
+
assertServerOnImport()
|
|
36
|
+
|
|
37
|
+
export interface AppToolContext {
|
|
38
|
+
/** Authenticated user id (Entra `oid`), resolved server-side. */
|
|
39
|
+
userId: string
|
|
40
|
+
/**
|
|
41
|
+
* Conversation this call belongs to, resolved server-side like `userId` — so a
|
|
42
|
+
* tool that writes into per-conversation storage (the Data Stash) cannot be
|
|
43
|
+
* pointed at someone else's conversation by the model. `null` off the request
|
|
44
|
+
* path (e.g. a background summarization); tools that need it must refuse
|
|
45
|
+
* rather than guess a session.
|
|
46
|
+
*/
|
|
47
|
+
sessionId: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AppToolDefinition {
|
|
51
|
+
name: string
|
|
52
|
+
description: string
|
|
53
|
+
/** JSON Schema shown to the model. MUST NOT contain a token/credential. */
|
|
54
|
+
inputSchema: Record<string, unknown>
|
|
55
|
+
/** Namespace for `ToolSet` grouping (e.g. `graph`), mirrors MCP servers. */
|
|
56
|
+
namespace: string
|
|
57
|
+
execute: (args: Record<string, unknown>, ctx: AppToolContext) => Promise<unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How the registry reads the caller's identity — injected by the composition
|
|
62
|
+
* root (`app-tools/index.server.ts`), which passes the app's
|
|
63
|
+
* `getRequestUserId`/`getRequestSessionId` pair. Same semantics as before the
|
|
64
|
+
* seam: `userId` null means no user scope, `sessionId` null means a user scope
|
|
65
|
+
* without a conversation.
|
|
66
|
+
*/
|
|
67
|
+
export interface AppToolResolveContext {
|
|
68
|
+
userId: () => string | null
|
|
69
|
+
sessionId: () => string | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AppToolRegistry {
|
|
73
|
+
/** Register an app-side tool. Later registration of the same name wins. */
|
|
74
|
+
registerAppTool(def: AppToolDefinition): void
|
|
75
|
+
/** Is this tool name handled in-process? Used by `callTool` dispatch. */
|
|
76
|
+
hasAppTool(name: string): boolean
|
|
77
|
+
/** Namespace for a registered app tool, or null when not one of ours. */
|
|
78
|
+
appToolNamespace(name: string): string | null
|
|
79
|
+
/** Advertise app tools alongside the gateway's, in the same shape. */
|
|
80
|
+
appToolDescriptions(): MCPToolDescription[]
|
|
81
|
+
/**
|
|
82
|
+
* Execute a registered app tool. Resolves the caller's user id and conversation
|
|
83
|
+
* through the injected `resolveContext` — established by
|
|
84
|
+
* `runWithRequestContext()` in both the interactive path (`runTurn`) and
|
|
85
|
+
* background runs (`runAgentInBackground`).
|
|
86
|
+
*/
|
|
87
|
+
runAppTool(name: string, args: Record<string, unknown>): Promise<ToolCallResult>
|
|
88
|
+
/** Test helper: drop all registrations. */
|
|
89
|
+
__resetAppTools(): void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Required-supplier check (PR-2 doctrine: throw, never degrade). */
|
|
93
|
+
function requireSupplier<T>(bag: unknown, field: string): T {
|
|
94
|
+
const value = (bag as Record<string, unknown> | null | undefined)?.[field]
|
|
95
|
+
if (typeof value !== 'function') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`createAppToolRegistry: missing required supplier "${field}" — the registry refuses to ` +
|
|
98
|
+
"guess an identity. Pass the app's getRequestUserId/getRequestSessionId pair.",
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return value as T
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build an app-tool registry around an injected identity resolver (design S3).
|
|
106
|
+
* The composition root calls this once and hands the resulting instance's
|
|
107
|
+
* `registerAppTool` to every tool-registration factory, so tool modules never
|
|
108
|
+
* import the registry module themselves either.
|
|
109
|
+
*/
|
|
110
|
+
export function createAppToolRegistry({
|
|
111
|
+
resolveContext,
|
|
112
|
+
}: {
|
|
113
|
+
resolveContext: AppToolResolveContext
|
|
114
|
+
}): AppToolRegistry {
|
|
115
|
+
const userId = requireSupplier<() => string | null>(resolveContext, 'userId')
|
|
116
|
+
const sessionId = requireSupplier<() => string | null>(resolveContext, 'sessionId')
|
|
117
|
+
|
|
118
|
+
const registry = new Map<string, AppToolDefinition>()
|
|
119
|
+
|
|
120
|
+
function registerAppTool(def: AppToolDefinition): void {
|
|
121
|
+
registry.set(def.name, def)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasAppTool(name: string): boolean {
|
|
125
|
+
return registry.has(name)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function appToolNamespace(name: string): string | null {
|
|
129
|
+
return registry.get(name)?.namespace ?? null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function appToolDescriptions(): MCPToolDescription[] {
|
|
133
|
+
return [...registry.values()].map((t) => ({
|
|
134
|
+
name: t.name,
|
|
135
|
+
description: t.description,
|
|
136
|
+
inputSchema: t.inputSchema,
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function __resetAppTools(): void {
|
|
141
|
+
registry.clear()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runAppTool(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
|
|
145
|
+
const def = registry.get(name)
|
|
146
|
+
if (!def) {
|
|
147
|
+
return { success: false, data: null, error: `Unknown app tool: ${name}` }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const id = userId()
|
|
151
|
+
if (!id) {
|
|
152
|
+
// No user scope — e.g. a background summarization path outside
|
|
153
|
+
// runWithRequestContext. Refuse rather than guess an identity.
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
data: null,
|
|
157
|
+
error: `${name} requires an authenticated user, but no user is in scope for this call.`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// sessionId may legitimately be null (a user scope without a conversation);
|
|
163
|
+
// it is the tool's job to refuse if it needs one.
|
|
164
|
+
return {
|
|
165
|
+
success: true,
|
|
166
|
+
data: await def.execute(args, { userId: id, sessionId: sessionId() }),
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
170
|
+
// Deliberately no stack/credential detail in the tool result — it flows
|
|
171
|
+
// into the model's context and the event log.
|
|
172
|
+
console.error(`[app-tools] ${name} failed:`, message)
|
|
173
|
+
return { success: false, data: null, error: message }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
registerAppTool,
|
|
179
|
+
hasAppTool,
|
|
180
|
+
appToolNamespace,
|
|
181
|
+
appToolDescriptions,
|
|
182
|
+
runAppTool,
|
|
183
|
+
__resetAppTools,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphAuthRequiredError — owned by the package so `instanceof` survives the
|
|
3
|
+
* seam (#225 PR-3, design S1).
|
|
4
|
+
*
|
|
5
|
+
* Raised when we cannot get a token without user interaction — no stored cache,
|
|
6
|
+
* an unusable/expired refresh token, or a scope the user hasn't consented to.
|
|
7
|
+
* Tool wrappers translate this into a "please sign in again" result rather than
|
|
8
|
+
* failing the whole run.
|
|
9
|
+
*
|
|
10
|
+
* The class moved here VERBATIM from the host's token module in PR-C2: the
|
|
11
|
+
* host's `graphFetch` implementation keeps throwing it (importing it from
|
|
12
|
+
* here), and the Graph tools check `instanceof` — one class, two importers,
|
|
13
|
+
* so the check keeps meaning the same thing across the seam.
|
|
14
|
+
*/
|
|
15
|
+
export class GraphAuthRequiredError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly userId: string,
|
|
19
|
+
/** HTTP status when Graph itself rejected the call (401 expired token,
|
|
20
|
+
* 403 missing consent OR resource-level denial such as SharePoint
|
|
21
|
+
* Embedded); undefined when token ACQUISITION failed before any HTTP
|
|
22
|
+
* request. Lets tools tell "sign in again" apart from "re-auth won't
|
|
23
|
+
* help" (e.g. Loop content, #137). */
|
|
24
|
+
readonly status?: number,
|
|
25
|
+
) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.name = 'GraphAuthRequiredError'
|
|
28
|
+
}
|
|
29
|
+
}
|