@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 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
+ }