@neuraltrust/trustgate 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.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +132 -0
  3. package/dist/agent.d.ts +156 -0
  4. package/dist/agent.d.ts.map +1 -0
  5. package/dist/agent.js +216 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/client.d.ts +88 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +152 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +44 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +40 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/connections.d.ts +13 -0
  16. package/dist/connections.d.ts.map +1 -0
  17. package/dist/connections.js +46 -0
  18. package/dist/connections.js.map +1 -0
  19. package/dist/errors.d.ts +91 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +144 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/formats.d.ts +44 -0
  24. package/dist/formats.d.ts.map +1 -0
  25. package/dist/formats.js +299 -0
  26. package/dist/formats.js.map +1 -0
  27. package/dist/http.d.ts +23 -0
  28. package/dist/http.d.ts.map +1 -0
  29. package/dist/http.js +94 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +10 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +10 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/mcp.d.ts +43 -0
  36. package/dist/mcp.d.ts.map +1 -0
  37. package/dist/mcp.js +175 -0
  38. package/dist/mcp.js.map +1 -0
  39. package/dist/schema.d.ts +52 -0
  40. package/dist/schema.d.ts.map +1 -0
  41. package/dist/schema.js +168 -0
  42. package/dist/schema.js.map +1 -0
  43. package/dist/types.d.ts +78 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +52 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/whoami.d.ts +73 -0
  48. package/dist/whoami.d.ts.map +1 -0
  49. package/dist/whoami.js +79 -0
  50. package/dist/whoami.js.map +1 -0
  51. package/package.json +29 -0
  52. package/src/agent.ts +267 -0
  53. package/src/client.ts +203 -0
  54. package/src/config.ts +78 -0
  55. package/src/connections.ts +90 -0
  56. package/src/errors.ts +154 -0
  57. package/src/formats.ts +362 -0
  58. package/src/http.ts +106 -0
  59. package/src/index.ts +41 -0
  60. package/src/mcp.ts +203 -0
  61. package/src/schema.ts +193 -0
  62. package/src/types.ts +98 -0
  63. package/src/whoami.ts +172 -0
package/src/schema.ts ADDED
@@ -0,0 +1,193 @@
1
+ import type { JSONSchema } from './types.js'
2
+
3
+ /**
4
+ * Turning a tool's JSON Schema into what a provider will accept.
5
+ *
6
+ * The gateway relays an upstream server's schema exactly as that server wrote
7
+ * it — that is the honest thing for a gateway to do, and it means the schema
8
+ * can use anything JSON Schema allows. Every provider's function calling
9
+ * accepts a smaller language than that. Translating is therefore the client's
10
+ * job, done here, once, against the format the caller asked for.
11
+ *
12
+ * Two of these conversions lose information, so both are reversible and the
13
+ * original schema is kept: what goes out closed and nullable has to come back
14
+ * open and absent, or the upstream rejects the call it was asked to make.
15
+ */
16
+
17
+ export type StrictResult = {
18
+ schema: JSONSchema
19
+ /** False when the schema uses something strict mode cannot express. */
20
+ strict: boolean
21
+ /** Why not, for the warning the caller gets. */
22
+ reason?: string
23
+ }
24
+
25
+ /**
26
+ * Inlines local `$ref`s.
27
+ *
28
+ * Nothing is lost: a reference and its target describe the same thing. It is
29
+ * separated from the strict pass because every provider needs it and none of
30
+ * them object to the result — which is why this is also the part that could
31
+ * one day move into the gateway.
32
+ */
33
+ export function inlineRefs(schema: JSONSchema): JSONSchema {
34
+ const defs = {
35
+ ...((schema.$defs as Record<string, JSONSchema>) ?? {}),
36
+ ...((schema.definitions as Record<string, JSONSchema>) ?? {}),
37
+ }
38
+ const seen = new Set<string>()
39
+
40
+ const walk = (node: unknown): unknown => {
41
+ if (Array.isArray(node)) return node.map(walk)
42
+ if (!isObject(node)) return node
43
+ const ref = node.$ref
44
+ if (typeof ref === 'string') {
45
+ const target = resolveRef(ref, defs)
46
+ // A cycle cannot be inlined; leaving the $ref in place makes the
47
+ // strict pass refuse the tool, which is better than looping.
48
+ if (target && !seen.has(ref)) {
49
+ seen.add(ref)
50
+ const resolved = walk({ ...target, ...omit(node, ['$ref']) })
51
+ seen.delete(ref)
52
+ return resolved
53
+ }
54
+ return node
55
+ }
56
+ const out: Record<string, unknown> = {}
57
+ for (const [key, value] of Object.entries(node)) {
58
+ if (key === '$defs' || key === 'definitions') continue
59
+ out[key] = walk(value)
60
+ }
61
+ return out
62
+ }
63
+
64
+ return walk(schema) as JSONSchema
65
+ }
66
+
67
+ function resolveRef(ref: string, defs: Record<string, JSONSchema>): JSONSchema | undefined {
68
+ const match = /^#\/(?:\$defs|definitions)\/(.+)$/.exec(ref)
69
+ if (!match) return undefined
70
+ return defs[decodeURIComponent(match[1])]
71
+ }
72
+
73
+ /**
74
+ * Rewrites a schema for OpenAI's strict function calling.
75
+ *
76
+ * Strict buys a guarantee worth having — the model cannot invent an argument —
77
+ * and charges for it in expressiveness: every object closed, every property
78
+ * required, and optionality expressed by accepting null. Schemas that use what
79
+ * strict cannot say are returned untouched with `strict: false`, because a tool
80
+ * the model can still call imperfectly beats a tool it cannot call at all.
81
+ */
82
+ export function toStrict(schema: JSONSchema): StrictResult {
83
+ const inlined = inlineRefs(schema)
84
+ let reason: string | undefined
85
+
86
+ const walk = (node: unknown): unknown => {
87
+ if (Array.isArray(node)) return node.map(walk)
88
+ if (!isObject(node)) return node
89
+ if ('$ref' in node) {
90
+ reason ??= 'it carries a $ref that does not resolve inside the schema'
91
+ return node
92
+ }
93
+ if ('allOf' in node) {
94
+ reason ??= 'it composes with allOf'
95
+ return node
96
+ }
97
+ if ('prefixItems' in node) {
98
+ reason ??= 'it uses prefixItems (tuple typing)'
99
+ return node
100
+ }
101
+
102
+ const out: Record<string, unknown> = {}
103
+ for (const [key, value] of Object.entries(node)) {
104
+ out[key] = key === 'required' ? value : walk(value)
105
+ }
106
+
107
+ if (out.type !== 'object' && !isObject(out.properties)) return out
108
+
109
+ if (out.additionalProperties !== undefined && out.additionalProperties !== false) {
110
+ reason ??= 'it accepts properties that are not in its schema'
111
+ return out
112
+ }
113
+ out.additionalProperties = false
114
+
115
+ const properties = (out.properties as Record<string, JSONSchema>) ?? {}
116
+ const required = new Set(Array.isArray(out.required) ? (out.required as string[]) : [])
117
+ const rewritten: Record<string, JSONSchema> = {}
118
+ for (const [name, property] of Object.entries(properties)) {
119
+ rewritten[name] = required.has(name) ? property : nullable(property)
120
+ }
121
+ out.properties = rewritten
122
+ // Strict wants every property named as required. What used to be
123
+ // optional stays optional in effect, by accepting null.
124
+ out.required = Object.keys(rewritten)
125
+ return out
126
+ }
127
+
128
+ const rewritten = walk(inlined) as JSONSchema
129
+ return reason ? { schema: inlined, strict: false, reason } : { schema: rewritten, strict: true }
130
+ }
131
+
132
+ /** Lets a schema accept null without losing what it already said. */
133
+ function nullable(schema: JSONSchema): JSONSchema {
134
+ if (typeof schema.type === 'string') {
135
+ return { ...schema, type: [schema.type, 'null'] }
136
+ }
137
+ if (Array.isArray(schema.type)) {
138
+ return schema.type.includes('null') ? schema : { ...schema, type: [...schema.type, 'null'] }
139
+ }
140
+ // No plain type to widen (an enum, an anyOf): offer null beside it.
141
+ return { anyOf: [schema, { type: 'null' }] }
142
+ }
143
+
144
+ /**
145
+ * Removes the nulls strict mode asked the model to send.
146
+ *
147
+ * `toStrict` made every optional property nullable so it could be named in
148
+ * `required`. The model takes that literally and sends `null` for the ones it
149
+ * has no value for — and the upstream server, which never agreed to any of
150
+ * this, rejects them. So the arguments are compared against the *original*
151
+ * schema on the way back, and a null it never permitted is dropped rather than
152
+ * forwarded.
153
+ */
154
+ export function stripInjectedNulls(args: unknown, original: JSONSchema | undefined): unknown {
155
+ if (Array.isArray(args)) {
156
+ const items = original?.items as JSONSchema | undefined
157
+ return args.map((item) => stripInjectedNulls(item, items))
158
+ }
159
+ if (!isObject(args)) return args
160
+ const properties = (original?.properties as Record<string, JSONSchema> | undefined) ?? {}
161
+ const out: Record<string, unknown> = {}
162
+ for (const [key, value] of Object.entries(args)) {
163
+ const property = properties[key]
164
+ if (value === null && !permitsNull(property)) continue
165
+ out[key] = stripInjectedNulls(value, property)
166
+ }
167
+ return out
168
+ }
169
+
170
+ function permitsNull(schema: JSONSchema | undefined): boolean {
171
+ // An unknown property is left alone: the upstream may accept keys this
172
+ // schema does not describe, and dropping one would lose a real argument.
173
+ if (!schema) return true
174
+ if (schema.type === 'null') return true
175
+ if (Array.isArray(schema.type) && schema.type.includes('null')) return true
176
+ const anyOf = schema.anyOf ?? schema.oneOf
177
+ if (Array.isArray(anyOf)) {
178
+ return anyOf.some((option) => permitsNull(option as JSONSchema))
179
+ }
180
+ return false
181
+ }
182
+
183
+ function isObject(value: unknown): value is Record<string, unknown> {
184
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
185
+ }
186
+
187
+ function omit(source: Record<string, unknown>, keys: string[]): Record<string, unknown> {
188
+ const out: Record<string, unknown> = {}
189
+ for (const [key, value] of Object.entries(source)) {
190
+ if (!keys.includes(key)) out[key] = value
191
+ }
192
+ return out
193
+ }
package/src/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { TrustGateError } from './errors.js'
2
+ /** Which actor a handle speaks as. The consumer decides this, not the caller. */
3
+ export const Actor = {
4
+ /** The application itself — principal `app:<consumer_id>`. */
5
+ Application: 'application',
6
+ /** One of the application's own end users. */
7
+ EndUser: 'end_user',
8
+ } as const
9
+ export type Actor = (typeof Actor)[keyof typeof Actor]
10
+
11
+ /**
12
+ * The tool shapes the SDK can emit.
13
+ *
14
+ * These are model providers, not agent frameworks. A framework brings its own
15
+ * MCP client, so it takes the gateway's URL and lists the tools itself — there
16
+ * is nothing to convert. Conversion only happens when you call a provider's
17
+ * API directly, which is why nothing here is called "langchain".
18
+ */
19
+ export const ToolFormat = {
20
+ /** OpenAI Responses API — function tools, flattened. */
21
+ OpenAIResponses: 'openai-responses',
22
+ /** OpenAI Chat Completions — function tools, nested under `function`. */
23
+ OpenAIChat: 'openai-chat',
24
+ /** Anthropic Messages — `input_schema`. */
25
+ AnthropicMessages: 'anthropic-messages',
26
+ /** Google Gemini — a single `functionDeclarations` entry. */
27
+ Gemini: 'gemini',
28
+ } as const
29
+ export type ToolFormat = (typeof ToolFormat)[keyof typeof ToolFormat]
30
+
31
+ /** A tool as the gateway serves it, before any provider dialect is applied. */
32
+ export type GatewayTool = {
33
+ name: string
34
+ title?: string
35
+ description?: string
36
+ inputSchema: JSONSchema
37
+ outputSchema?: JSONSchema
38
+ }
39
+
40
+ export type JSONSchema = Record<string, unknown>
41
+
42
+ /** One upstream account of an actor, as the connections API reports it. */
43
+ export type Connection = {
44
+ provider: string
45
+ registry?: string
46
+ code?: string
47
+ status: 'connected' | 'needs_reconnect' | 'not_connected'
48
+ accountRef?: string
49
+ expiresAt?: Date
50
+ }
51
+
52
+ /** The link an end user opens to connect their own account. */
53
+ export type ConnectLink = {
54
+ connectUrl: string
55
+ ticket: string
56
+ provider?: string
57
+ expiresAt: Date
58
+ }
59
+
60
+ /** Everything a provider's client needs to reach the gateway. */
61
+ export type Endpoint = {
62
+ url: string
63
+ headers: Record<string, string>
64
+ }
65
+
66
+ /** A tool call as the SDK understands it, whatever provider asked for it. */
67
+ export type ToolCall = {
68
+ /** The provider's own identifier for this call, echoed back in the result. */
69
+ id: string
70
+ name: string
71
+ arguments: Record<string, unknown>
72
+ }
73
+
74
+ /**
75
+ * The exposed name of a tool the caller named.
76
+ *
77
+ * The gateway prefixes every tool with the server it came from, so Linear's
78
+ * `list_issues` is served as `linear_list_issues`. That prefix is the gateway's
79
+ * doing and not the caller's, so a name written without one resolves — as long
80
+ * as exactly one server serves it. Two that do is a genuine question only the
81
+ * caller can answer, and it is asked rather than guessed.
82
+ *
83
+ * A name that matches nothing is returned unchanged, so the error that follows
84
+ * is about the tool rather than about this.
85
+ */
86
+ export function resolveToolName(name: string, tools: GatewayTool[]): string {
87
+ const names = tools.map((tool) => tool.name)
88
+ if (names.includes(name)) return name
89
+ const matches = names.filter((candidate) => candidate.endsWith(`_${name}`))
90
+ if (matches.length === 1) return matches[0]
91
+ if (matches.length > 1) {
92
+ throw new TrustGateError(
93
+ `"${name}" is served by more than one of this application's servers ` +
94
+ `(${[...matches].sort().join(', ')}). Name the one you mean.`
95
+ )
96
+ }
97
+ return name
98
+ }
package/src/whoami.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type { ResolvedConfig } from './config.js'
2
+ import { PlaneUnavailableError, TrustGateError } from './errors.js'
3
+ import { requestJSON } from './http.js'
4
+
5
+ /** Whose account an MCP server reads: the one its instance holds, or one per caller. */
6
+ export type UpstreamAccount = 'shared' | 'user'
7
+
8
+ /**
9
+ * Who has to act before a server answers a call that runs as the application.
10
+ *
11
+ * `administrator` is an instance whose shared account nobody has connected —
12
+ * no caller can connect it, because it is the account every other caller rides
13
+ * on. `end_user` is an instance that keeps an account per caller, which an
14
+ * application is not: it names the person it acts for, and the account becomes
15
+ * theirs to connect.
16
+ */
17
+ export type UpstreamBlockedBy = 'administrator' | 'end_user'
18
+
19
+ /** One MCP server the application is bound to, and what it is waiting for. */
20
+ export type KeyUpstream = {
21
+ server: string
22
+ provider?: string
23
+ account: UpstreamAccount
24
+ connected: boolean
25
+ needsReconnect: boolean
26
+ blocked?: UpstreamBlockedBy
27
+ }
28
+
29
+ /** One consumer the key reaches, with the address it is served on. */
30
+ export type KeyConsumer = {
31
+ slug: string
32
+ name?: string
33
+ /** The plane this consumer belongs to: MCP, LLM or A2A. */
34
+ type: string
35
+ active: boolean
36
+ /** Where it answers. Empty when the gateway publishes no host for its plane. */
37
+ url: string
38
+ /**
39
+ * The servers behind it that read a stored account, answered for this key —
40
+ * which is the application itself.
41
+ *
42
+ * `undefined` is not "nothing to connect": it is also what a gateway that
43
+ * could not read the accounts answers, and a server carrying its own
44
+ * credential is never listed. Read `blocked`, never a length.
45
+ */
46
+ upstreams?: KeyUpstream[]
47
+ }
48
+
49
+ /** The calling key itself. The secret is never echoed. */
50
+ export type KeyInfo = {
51
+ name?: string
52
+ /** When it retires itself. `undefined` means never. */
53
+ expiresAt?: Date
54
+ }
55
+
56
+ /** Everything the key can say about itself. */
57
+ export type KeyIdentity = {
58
+ gateway: string
59
+ key: KeyInfo
60
+ consumers: KeyConsumer[]
61
+ }
62
+
63
+ type Payload = {
64
+ gateway?: string
65
+ key?: { name?: string; expires_at?: string }
66
+ consumers?: {
67
+ slug: string
68
+ name?: string
69
+ type: string
70
+ active: boolean
71
+ url?: string
72
+ upstreams?: {
73
+ server?: string
74
+ provider?: string
75
+ account?: string
76
+ connected?: boolean
77
+ needs_reconnect?: boolean
78
+ blocked?: string
79
+ }[]
80
+ }[]
81
+ }
82
+
83
+ /**
84
+ * Asks the key what it reaches, when it dies, and what is not connected yet.
85
+ *
86
+ * It is what lets a client be configured with one secret: the slugs were
87
+ * chosen by whoever created the consumers, and the LLM plane is on a host the
88
+ * MCP URL says nothing about, so both have to come from the gateway. The other
89
+ * two answers are the failures a client would otherwise meet at runtime — an
90
+ * expired key as a 401 mid-run, an unconnected server as a refusal on the
91
+ * first tool call — moved to where something can still be done about them.
92
+ */
93
+ export async function whoAmI(config: ResolvedConfig, signal?: AbortSignal): Promise<KeyIdentity> {
94
+ const { body } = await requestJSON<Payload>(config, 'GET', '/whoami', { signal })
95
+ return {
96
+ gateway: body?.gateway ?? '',
97
+ key: {
98
+ name: body?.key?.name || undefined,
99
+ expiresAt: parseDate(body?.key?.expires_at),
100
+ },
101
+ consumers: (body?.consumers ?? []).map((consumer) => ({
102
+ slug: consumer.slug,
103
+ name: consumer.name || undefined,
104
+ type: String(consumer.type ?? '').toUpperCase(),
105
+ active: consumer.active !== false,
106
+ url: consumer.url ?? '',
107
+ upstreams: consumer.upstreams?.map((upstream) => ({
108
+ server: upstream.server ?? '',
109
+ provider: upstream.provider || undefined,
110
+ account: upstream.account === 'shared' ? 'shared' : 'user',
111
+ connected: upstream.connected === true,
112
+ needsReconnect: upstream.needs_reconnect === true,
113
+ blocked: blockedBy(upstream.blocked),
114
+ })),
115
+ })),
116
+ }
117
+ }
118
+
119
+ function blockedBy(raw: string | undefined): UpstreamBlockedBy | undefined {
120
+ return raw === 'administrator' || raw === 'end_user' ? raw : undefined
121
+ }
122
+
123
+ function parseDate(raw: string | undefined): Date | undefined {
124
+ if (!raw) return undefined
125
+ const at = new Date(raw)
126
+ return Number.isNaN(at.getTime()) ? undefined : at
127
+ }
128
+
129
+ /**
130
+ * Picks the one consumer of a plane, or explains why it cannot.
131
+ *
132
+ * A key attached to two consumers of the same type is a legitimate setup that
133
+ * this SDK cannot resolve on its own, so it names them and asks — rather than
134
+ * guessing and running an agent against the wrong surface.
135
+ */
136
+ export function selectConsumer(
137
+ identity: KeyIdentity,
138
+ plane: 'MCP' | 'LLM',
139
+ configured: string | undefined,
140
+ envName: string
141
+ ): KeyConsumer {
142
+ const candidates = identity.consumers.filter((consumer) => consumer.type === plane)
143
+ if (configured) {
144
+ const named = candidates.find((consumer) => consumer.slug === configured)
145
+ if (named) return named
146
+ throw new PlaneUnavailableError(
147
+ `this API key does not reach a ${plane} consumer called "${configured}"` +
148
+ (candidates.length ? `; it reaches ${candidates.map((c) => c.slug).join(', ')}` : '')
149
+ )
150
+ }
151
+ if (candidates.length === 0) {
152
+ throw new PlaneUnavailableError(
153
+ `this API key reaches no ${plane} consumer. ` +
154
+ 'Ask the admin who owns this application to attach one, or name it explicitly.'
155
+ )
156
+ }
157
+ if (candidates.length > 1) {
158
+ throw new TrustGateError(
159
+ `this API key reaches several ${plane} consumers (${candidates
160
+ .map((consumer) => consumer.slug)
161
+ .join(', ')}); name the one this agent uses.`
162
+ )
163
+ }
164
+ const only = candidates[0]
165
+ if (!only.url) {
166
+ throw new PlaneUnavailableError(
167
+ `the gateway publishes no host for its ${plane} plane, so "${only.slug}" has no address. ` +
168
+ `Set ${envName} and a base URL for it, or ask an operator to configure that plane's domain.`
169
+ )
170
+ }
171
+ return only
172
+ }