@xingular/harness 0.0.0-stage → 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/README.md +269 -2
- package/dist/cli.js +11046 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.ts +204 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +289 -0
- package/src/index.ts +69 -0
- package/src/mcp/connections.ts +441 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +567 -0
- package/src/mcp/types.ts +105 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +287 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +128 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +250 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +44 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +184 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +130 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +308 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +394 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// Connected apps as sessions use them: which connections a session may reach, the two broker
|
|
2
|
+
// tools every such session gets, approval of anything that is not a read, and a redacted record
|
|
3
|
+
// of what each call returned.
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import type { SecretStore } from '../accounts/secrets'
|
|
8
|
+
import type { Bus } from '../core/bus'
|
|
9
|
+
import type { HarnessEnv } from '../core/env'
|
|
10
|
+
import { HarnessError, invalid, notFound } from '../core/errors'
|
|
11
|
+
import { newId, now, readJson, writeJson } from '../core/fs'
|
|
12
|
+
import { charCount, takeChars } from '../core/text'
|
|
13
|
+
import type { CapabilitySet } from '../capabilities/types'
|
|
14
|
+
import type { CallerContext } from '../memory/caller'
|
|
15
|
+
import type { ToolPolicy } from '../sessions/policy'
|
|
16
|
+
import type { ActiveRun, RunRegistry } from '../sessions/registry'
|
|
17
|
+
import { findRun, runDir } from '../sessions/runStore'
|
|
18
|
+
import type { SessionBroker } from '../sessions/sessions'
|
|
19
|
+
import type { ToolSession, ToolSpec } from '../tools/types'
|
|
20
|
+
import { allowed, Connections, setScope, webUrl } from './connections'
|
|
21
|
+
import { isSignInRequired, McpManager, signInRequired } from './manager'
|
|
22
|
+
import { signIn, type OAuthHost } from './oauth'
|
|
23
|
+
import type { Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
|
|
24
|
+
|
|
25
|
+
/** A session's access to the broker. A terminal session presents `token`; an SDK session holds the object. */
|
|
26
|
+
export interface BrokerSession {
|
|
27
|
+
token: string
|
|
28
|
+
sessionId: string
|
|
29
|
+
runId: string | null
|
|
30
|
+
caller: CallerContext
|
|
31
|
+
/** Only reads are permitted. */
|
|
32
|
+
readOnly: boolean
|
|
33
|
+
depth: number
|
|
34
|
+
/** The run's immutable tool and connector set. */
|
|
35
|
+
capabilities?: CapabilitySet
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BrokerTool {
|
|
39
|
+
name: string
|
|
40
|
+
description?: string
|
|
41
|
+
inputSchema?: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Tools the embedding application offers through the broker, beside connected apps. */
|
|
45
|
+
export interface BrokeredTools {
|
|
46
|
+
list(session: BrokerSession): Promise<BrokerTool[]>
|
|
47
|
+
has(name: string): boolean
|
|
48
|
+
/** The tool's result as the application returns it: text, `{ text, image? }`, or structured data. */
|
|
49
|
+
call(session: BrokerSession, name: string, args: Record<string, unknown>): Promise<unknown>
|
|
50
|
+
/** Changes whenever the offered tools may have changed. */
|
|
51
|
+
revision?(session: BrokerSession): Promise<unknown>
|
|
52
|
+
/** A sentence added to the discovery tool's description, e.g. where the application lists the rest of its tools. */
|
|
53
|
+
discoveryNote?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ListedTool extends BrokerTool {
|
|
57
|
+
connectionId: string
|
|
58
|
+
tool: string
|
|
59
|
+
origin?: 'app'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TOOL_SUMMARY_CHARS = 160
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A tool listing a model can scan: a name and one line each. Input schemas are the bulk of a
|
|
66
|
+
* discovery reply, so they come back for one named tool at a time.
|
|
67
|
+
*/
|
|
68
|
+
export function compactTools<T extends BrokerTool>(tools: readonly T[], tool?: string): Array<Omit<T, 'inputSchema'> | T> {
|
|
69
|
+
if (tool) return tools.filter((entry) => entry.name === tool)
|
|
70
|
+
return tools.map(({ inputSchema: _schema, ...entry }) => ({
|
|
71
|
+
...entry,
|
|
72
|
+
...(entry.description && entry.description.length > TOOL_SUMMARY_CHARS ? { description: `${entry.description.slice(0, TOOL_SUMMARY_CHARS).trimEnd()}…` } : {})
|
|
73
|
+
})) as Array<Omit<T, 'inputSchema'>>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Most tools one listing returns. A server with hundreds of tools would otherwise make every discovery call a very large input. */
|
|
77
|
+
export const TOOL_PAGE = 40
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One page of a tool listing: names and summaries only, optionally filtered by a
|
|
81
|
+
* case-insensitive `query` over name and description. `more` and `nextOffset` appear when
|
|
82
|
+
* entries remain.
|
|
83
|
+
*/
|
|
84
|
+
export function pageTools<T extends BrokerTool>(tools: readonly T[], options: { query?: string; offset?: number; limit?: number } = {}) {
|
|
85
|
+
const query = options.query?.trim().toLowerCase()
|
|
86
|
+
const matching = query ? tools.filter((tool) => tool.name.toLowerCase().includes(query) || (tool.description ?? '').toLowerCase().includes(query)) : tools
|
|
87
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0))
|
|
88
|
+
const limit = Math.max(1, Math.min(Math.floor(options.limit ?? TOOL_PAGE), 100))
|
|
89
|
+
const page = compactTools(matching.slice(offset, offset + limit))
|
|
90
|
+
const next = offset + page.length
|
|
91
|
+
return { tools: page, total: matching.length, ...(next < matching.length ? { more: matching.length - next, nextOffset: next } : {}) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const WORKSPACE_READS = new Set([
|
|
95
|
+
'x_workspace_status', 'x_workspace_context', 'x_workspace_search', 'x_workspace_note_list', 'x_workspace_note_read', 'x_workspace_project_list', 'x_workspace_project_get',
|
|
96
|
+
'x_workspace_task_list', 'x_workspace_task_get', 'x_workspace_milestone_list', 'x_workspace_milestone_get', 'x_workspace_update_list', 'x_workspace_update_get',
|
|
97
|
+
'x_workspace_meeting_list', 'x_workspace_meeting_get', 'x_workspace_resource_list', 'x_workspace_resource_get', 'x_workspace_resource_preview'
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
interface Proposal {
|
|
101
|
+
runId: string
|
|
102
|
+
sessionId: string
|
|
103
|
+
connectionId: string
|
|
104
|
+
data: unknown
|
|
105
|
+
expiresAt: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface AuthorizedCall {
|
|
109
|
+
session: BrokerSession
|
|
110
|
+
connection: Connection
|
|
111
|
+
runId: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ConnectedAppsOptions {
|
|
115
|
+
env: HarnessEnv
|
|
116
|
+
stateDir: string
|
|
117
|
+
secrets: SecretStore
|
|
118
|
+
bus: Bus
|
|
119
|
+
runs: RunRegistry
|
|
120
|
+
policy: ToolPolicy
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The scope a plugin hook's connections are opened under: they belong to no session. */
|
|
124
|
+
const UNATTENDED_SCOPE = 'plugin-hooks'
|
|
125
|
+
|
|
126
|
+
export class ConnectedApps implements SessionBroker {
|
|
127
|
+
readonly toolNames = ['mcp_list_tools', 'mcp_call_tool'] as const
|
|
128
|
+
readonly connections: Connections
|
|
129
|
+
private readonly manager: McpManager
|
|
130
|
+
private readonly oauth: OAuthHost
|
|
131
|
+
private readonly grants = new Map<string, BrokerSession>()
|
|
132
|
+
private readonly proposals = new Map<string, Proposal>()
|
|
133
|
+
private readonly calls = new Map<string, AuthorizedCall>()
|
|
134
|
+
private brokered: BrokeredTools | null = null
|
|
135
|
+
|
|
136
|
+
constructor(private readonly options: ConnectedAppsOptions) {
|
|
137
|
+
this.connections = new Connections(options.stateDir, options.secrets, options.bus)
|
|
138
|
+
this.oauth = {
|
|
139
|
+
clientName: options.env.productName,
|
|
140
|
+
load: (id) => this.connections.oauthLoad(id),
|
|
141
|
+
save: (id, state) => this.connections.oauthSave(id, state),
|
|
142
|
+
openUrl: (url) => {
|
|
143
|
+
if (!webUrl(url)) throw invalid('The sign-in page must be an https:// address.')
|
|
144
|
+
// The application shows the page; the harness never opens a browser by itself.
|
|
145
|
+
options.bus.emit({ type: 'connections.openUrl', url })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.manager = new McpManager(this.oauth)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Loads what is needed to redact results. Call once before sessions start. */
|
|
152
|
+
start(): Promise<void> {
|
|
153
|
+
return this.connections.refreshSecrets()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Lets the application offer its own tools through `mcp_list_tools` and `mcp_call_tool`. */
|
|
157
|
+
expose(tools: BrokeredTools): void {
|
|
158
|
+
this.brokered = tools
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- Grants -----------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Gives a session access to the broker. The token is what a process outside the harness presents. */
|
|
164
|
+
grant(input: Omit<BrokerSession, 'token'>): BrokerSession {
|
|
165
|
+
const session: BrokerSession = { ...input, token: `${newId()}${newId()}` }
|
|
166
|
+
this.grants.set(session.token, session)
|
|
167
|
+
return session
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
session(token: string): BrokerSession {
|
|
171
|
+
const session = this.grants.get(token)
|
|
172
|
+
if (!session) throw new HarnessError('mcp_unauthorized', 'This MCP session is no longer authorized.')
|
|
173
|
+
return session
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
revokeSession(sessionId: string): void {
|
|
177
|
+
for (const [token, session] of this.grants) if (session.sessionId === sessionId) this.grants.delete(token)
|
|
178
|
+
for (const [key, proposal] of this.proposals) if (proposal.sessionId === sessionId) this.proposals.delete(key)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A run ended: its grants, previews, and open client sessions go with it. */
|
|
182
|
+
revokeRun(runId: string, sessionId: string): void {
|
|
183
|
+
for (const [token, session] of this.grants) if (session.runId === runId) this.grants.delete(token)
|
|
184
|
+
for (const [key, proposal] of this.proposals) if (proposal.runId === runId) this.proposals.delete(key)
|
|
185
|
+
void this.manager.close(undefined, sessionId)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private activeRun(session: BrokerSession): ActiveRun {
|
|
189
|
+
const run = (session.runId ? this.options.runs.active(session.runId) : null) ?? this.options.runs.activeForSession(session.sessionId)
|
|
190
|
+
if (!run) throw new HarnessError('run_not_active', 'The requesting run has ended.')
|
|
191
|
+
return run
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private valid(session: BrokerSession): void {
|
|
195
|
+
if (this.grants.get(session.token) !== session) throw new HarnessError('mcp_unauthorized', 'This MCP session is no longer authorized.')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- What a session may reach ---------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
private async reachable(session: BrokerSession): Promise<Connection[]> {
|
|
201
|
+
const scoped = this.connections.forScope({ agentId: session.caller.agentId, projectId: session.caller.projectId })
|
|
202
|
+
const frozen = session.capabilities ? scoped.filter((connection) => session.capabilities!.connectors.includes(connection.id)) : scoped
|
|
203
|
+
const policy = this.options.policy.connections
|
|
204
|
+
// Nothing to narrow: the application is not asked.
|
|
205
|
+
if (!policy || !frozen.length) return frozen
|
|
206
|
+
const ids = await policy({ sessionId: session.sessionId, runId: session.runId, caller: session.caller, connectionIds: frozen.map((connection) => connection.id) })
|
|
207
|
+
return frozen.filter((connection) => ids.includes(connection.id))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The application's tools and every reachable connection's tools, with the connections that could not be read. */
|
|
211
|
+
async list(session: BrokerSession): Promise<{ tools: ListedTool[]; errors: { connection: string; message: string }[] }> {
|
|
212
|
+
const own = (await this.brokered?.list(session)) ?? []
|
|
213
|
+
const discovered = await Promise.all(
|
|
214
|
+
(await this.reachable(session)).map(async (connection) => {
|
|
215
|
+
const tools: ListedTool[] = []
|
|
216
|
+
let config: LaunchConfig | null = null
|
|
217
|
+
try {
|
|
218
|
+
config = await this.connections.launch(connection)
|
|
219
|
+
const client = await this.manager.client(session.sessionId, config)
|
|
220
|
+
for (const tool of (await client.listTools({}, { timeout: 15000 })).tools) {
|
|
221
|
+
const alias = `mcp_${createHash('sha256').update(`${connection.id}:${tool.name}`).digest('hex').slice(0, 12)}_${tool.name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 40)}`
|
|
222
|
+
tools.push({ name: alias, description: `${connection.name}: ${tool.description ?? tool.name}`, inputSchema: tool.inputSchema, connectionId: connection.id, tool: tool.name })
|
|
223
|
+
}
|
|
224
|
+
if (client.getServerCapabilities()?.resources) {
|
|
225
|
+
for (const operation of ['list', 'read'] as const) {
|
|
226
|
+
tools.push({
|
|
227
|
+
name: `mcp_${connection.id.replace(/-/g, '').slice(0, 12)}_resources_${operation}`,
|
|
228
|
+
description: `${connection.name}: ${operation} MCP resources`,
|
|
229
|
+
inputSchema: { type: 'object', properties: operation === 'read' ? { uri: { type: 'string' } } : {}, ...(operation === 'read' ? { required: ['uri'] } : {}) },
|
|
230
|
+
connectionId: connection.id,
|
|
231
|
+
tool: `resources/${operation}`
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { tools, errors: [] }
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const unauthorized = isSignInRequired(error)
|
|
238
|
+
const message = unauthorized ? signInRequired(connection).message : `${connection.name} could not load its tools. Check the connection in Settings → Connections.`
|
|
239
|
+
if (unauthorized) await this.manager.close(connection.id, session.sessionId)
|
|
240
|
+
this.connectionFailed(connection.id, unauthorized)
|
|
241
|
+
return { tools: [], errors: [{ connection: connection.name, message }] }
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
)
|
|
245
|
+
return {
|
|
246
|
+
tools: [...own.map((tool): ListedTool => ({ ...tool, origin: 'app', connectionId: '__app__', tool: tool.name })), ...discovered.flatMap((entry) => entry.tools)],
|
|
247
|
+
errors: discovered.flatMap((entry) => entry.errors)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private connectionFailed(connectionId: string, unauthorized: boolean): void {
|
|
252
|
+
try {
|
|
253
|
+
this.connections.update(connectionId, (item) => {
|
|
254
|
+
if (item.auth === 'oauth' && unauthorized) {
|
|
255
|
+
item.authState = 'expired'
|
|
256
|
+
item.error = `${item.name} needs you to sign in again.`
|
|
257
|
+
} else if (item.kind === 'remote') {
|
|
258
|
+
item.error = `${item.name} could not load its tools. Check its address and sign-in, then test or reconnect.`
|
|
259
|
+
} else {
|
|
260
|
+
item.error = `${item.name} could not load its tools. Check its executable and arguments, then test or reconnect.`
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
this.connections.changed()
|
|
264
|
+
} catch {
|
|
265
|
+
// The connection was removed while it was being read.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- Calling ------------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** Runs one tool by the name `list` gave it. */
|
|
272
|
+
async invoke(session: BrokerSession, alias: string, args: Record<string, unknown>): Promise<unknown> {
|
|
273
|
+
if (this.brokered?.has(alias)) {
|
|
274
|
+
const result = await this.brokered.call(session, alias, args)
|
|
275
|
+
const output = result as { text?: string; image?: { data: string; mimeType: string } } | null
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: 'text', text: output?.text ?? (typeof result === 'string' ? result : JSON.stringify(result)) }, ...(output?.image ? [{ type: 'image', ...output.image }] : [])],
|
|
278
|
+
...(!output?.image && result && typeof result === 'object' ? { structuredContent: Array.isArray(result) ? { result } : result } : {})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const tool = (await this.list(session)).tools.find((candidate) => candidate.name === alias)
|
|
282
|
+
if (!tool) throw new HarnessError('mcp_tool_unavailable', 'This MCP tool is unavailable or its connection was disabled.')
|
|
283
|
+
const toolCallId = randomUUID()
|
|
284
|
+
const config = await this.authorize(session, tool.connectionId, tool.tool, args, toolCallId)
|
|
285
|
+
try {
|
|
286
|
+
const client = await this.manager.client(session.sessionId, config)
|
|
287
|
+
const result = tool.tool === 'resources/list' ? await client.listResources() : tool.tool === 'resources/read' ? await client.readResource({ uri: String(args.uri) }) : await client.callTool({ name: tool.tool, arguments: args }, { timeout: 60000 })
|
|
288
|
+
this.completed(toolCallId, tool.tool, { result })
|
|
289
|
+
return result
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const message = error instanceof Error ? error.message : 'MCP call failed'
|
|
292
|
+
this.completed(toolCallId, tool.tool, { error: tool.tool === 'x_workspace_mutation_apply' ? 'Workspace apply did not return a confirmed result. Do not retry automatically; read the affected record to verify its state.' : message })
|
|
293
|
+
throw error
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Checks scope, asks for approval when the call is not a read, and returns how to reach the connection. */
|
|
298
|
+
private async authorize(session: BrokerSession, connectionId: string, tool: string, args: Record<string, unknown>, toolCallId: string): Promise<LaunchConfig> {
|
|
299
|
+
const run = this.activeRun(session)
|
|
300
|
+
const record = findRun(this.options.env.root, run.runId)
|
|
301
|
+
if (record) {
|
|
302
|
+
const scope = await this.options.policy.scope?.({ run: record, caller: session.caller, tool: 'connected_app', args })
|
|
303
|
+
if (scope && scope.automatic && !scope.allowed) throw new HarnessError('outside_work_scope', scope.reason ?? 'Outside the approved work scope.')
|
|
304
|
+
}
|
|
305
|
+
const denied = (): HarnessError => new HarnessError('mcp_access_denied', 'This connection is disabled or unavailable to this agent.')
|
|
306
|
+
const connection = (await this.reachable(session)).find((item) => item.id === connectionId)
|
|
307
|
+
if (!connection) throw denied()
|
|
308
|
+
const workspace = connection.kind === 'workspace'
|
|
309
|
+
const preview = workspace && tool === 'x_workspace_mutation_preview'
|
|
310
|
+
const apply = workspace && tool === 'x_workspace_mutation_apply'
|
|
311
|
+
const isRead = (workspace && WORKSPACE_READS.has(tool)) || (!apply && !preview && connection.readTools.includes(tool))
|
|
312
|
+
if (session.readOnly && !isRead) throw new HarnessError('mcp_read_only', 'This session only permits reads from connected apps.')
|
|
313
|
+
let proposal: unknown
|
|
314
|
+
if (apply) {
|
|
315
|
+
const approvalToken = typeof args.approvalToken === 'string' ? args.approvalToken : ''
|
|
316
|
+
const saved = this.proposals.get(approvalToken)
|
|
317
|
+
this.proposals.delete(approvalToken)
|
|
318
|
+
if (!saved) throw new HarnessError('mcp_preview_required', 'Create a fresh preview in this run before applying.')
|
|
319
|
+
if (saved.sessionId !== session.sessionId || saved.runId !== run.runId || saved.connectionId !== connectionId || saved.expiresAt <= now()) throw new HarnessError('mcp_preview_required', 'This preview belongs to another run or has expired.')
|
|
320
|
+
proposal = saved.data
|
|
321
|
+
}
|
|
322
|
+
run.emit('tool.started', { toolCallId, name: tool, summary: connection.name })
|
|
323
|
+
if (!isRead && !preview) {
|
|
324
|
+
const action = JSON.stringify(proposal ?? this.connections.scrub(args, connection.id), null, 2)
|
|
325
|
+
const decision = await run.requestApproval({ requestId: `mcp-${newId()}`, target: connection.name, action: `${tool}\n${action}`, standing: 'switch', timeoutMs: 300_000 })
|
|
326
|
+
if (decision !== 'approved') {
|
|
327
|
+
run.emit('tool.completed', { toolCallId, name: tool, ok: false })
|
|
328
|
+
throw new HarnessError('mcp_denied', 'The user denied the MCP operation or its approval expired.')
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Access may have been revoked, or the run ended, while the request was waiting.
|
|
332
|
+
const current = (await this.reachable(session)).find((item) => item.id === connectionId)
|
|
333
|
+
if (!current) throw new HarnessError('mcp_access_denied', 'Connection access was revoked while waiting.')
|
|
334
|
+
this.valid(session)
|
|
335
|
+
this.activeRun(session)
|
|
336
|
+
this.calls.set(toolCallId, { session, connection: current, runId: run.runId })
|
|
337
|
+
return this.connections.launch(current)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Records what an authorized call returned: a redacted trace with its sources, and the tool's outcome. */
|
|
341
|
+
private completed(toolCallId: string, tool: string, outcome: { result?: unknown; error?: string }): void {
|
|
342
|
+
const call = this.calls.get(toolCallId)
|
|
343
|
+
if (!call) return
|
|
344
|
+
this.calls.delete(toolCallId)
|
|
345
|
+
const { connection, session, runId } = call
|
|
346
|
+
const result = (outcome.result ?? null) as { structuredContent?: { data?: { approvalToken?: unknown; expiresAt?: unknown }; truncated?: unknown }; isError?: unknown } | null
|
|
347
|
+
const structured = result?.structuredContent
|
|
348
|
+
const live = this.grants.get(session.token) === session && this.options.runs.active(runId) !== null
|
|
349
|
+
if (tool === 'x_workspace_mutation_preview' && live && typeof structured?.data?.approvalToken === 'string') {
|
|
350
|
+
this.proposals.set(structured.data.approvalToken, { runId, sessionId: session.sessionId, connectionId: connection.id, data: this.connections.scrub(structured.data, connection.id), expiresAt: typeof structured.data.expiresAt === 'string' ? structured.data.expiresAt : '' })
|
|
351
|
+
}
|
|
352
|
+
const ok = outcome.error === undefined && result?.isError !== true
|
|
353
|
+
const cleaned = this.connections.scrub(result, connection.id) as { structuredContent?: { sources?: unknown } } | null
|
|
354
|
+
const raw = outcome.error === undefined ? JSON.stringify(cleaned) : String(this.connections.scrub(outcome.error, connection.id))
|
|
355
|
+
const found = cleaned?.structuredContent?.sources
|
|
356
|
+
const sources: McpSource[] = (Array.isArray(found) ? found : [])
|
|
357
|
+
.flatMap((source: Record<string, unknown> | null) => {
|
|
358
|
+
if (!source || typeof source.id !== 'string' || typeof source.title !== 'string' || typeof source.uri !== 'string') return []
|
|
359
|
+
const value: McpSource = { id: source.id, title: source.title, uri: source.uri, path: typeof source.path === 'string' ? source.path : null }
|
|
360
|
+
for (const field of ['revision', 'excerpt', 'projectId'] as const) if (typeof source[field] === 'string') value[field] = takeChars(source[field] as string, 2000)
|
|
361
|
+
return [value]
|
|
362
|
+
})
|
|
363
|
+
.slice(0, 100)
|
|
364
|
+
const trace: McpTrace = { id: toolCallId, connectionId: connection.id, connectionName: connection.name, tool, at: now(), ok, summary: tool, result: takeChars(raw, 24000), sources, truncated: charCount(raw) > 24000 || structured?.truncated === true }
|
|
365
|
+
try {
|
|
366
|
+
const file = join(runDir(this.options.env.root, runId), 'mcp-context.json')
|
|
367
|
+
writeJson(file, [...(readJson<McpTrace[]>(file) ?? []), trace])
|
|
368
|
+
} catch {
|
|
369
|
+
// The call happened; a trace that cannot be written does not undo it.
|
|
370
|
+
}
|
|
371
|
+
this.options.runs.active(runId)?.emit('tool.completed', { toolCallId, name: tool, ok })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** What a run retrieved from connected apps, in order. */
|
|
375
|
+
runContext(runId: string): McpTrace[] {
|
|
376
|
+
return readJson<McpTrace[]>(join(runDir(this.options.env.root, runId), 'mcp-context.json')) ?? []
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Changes whenever what a session can discover may have changed. */
|
|
380
|
+
async revision(session: BrokerSession): Promise<unknown> {
|
|
381
|
+
return { connections: this.connections.revision(), tools: (await this.brokered?.revision?.(session)) ?? null }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- The two tools a session gets -------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
tools(session: ToolSession, options: { readOnly: boolean }): ToolSpec[] {
|
|
387
|
+
const granted = this.grant({ sessionId: session.run.sessionId, runId: session.run.runId, caller: session.caller, readOnly: options.readOnly, depth: session.depth, capabilities: session.capabilities })
|
|
388
|
+
return this.brokerTools(granted)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** `mcp_list_tools` and `mcp_call_tool` bound to a granted session. */
|
|
392
|
+
brokerTools(session: BrokerSession): ToolSpec[] {
|
|
393
|
+
return [
|
|
394
|
+
{
|
|
395
|
+
name: 'mcp_list_tools',
|
|
396
|
+
label: 'App and connected tools',
|
|
397
|
+
description: `Discover enabled ${this.options.env.productName} app operations and connected-app tools by name. Pass tool to get one tool with its input schema.${this.brokered?.discoveryNote ? ` ${this.brokered.discoveryNote}` : ''}`,
|
|
398
|
+
parameters: (Type) =>
|
|
399
|
+
Type.Object({
|
|
400
|
+
tool: Type.Optional(Type.String({ description: 'One tool name, to get its input schema.' })),
|
|
401
|
+
query: Type.Optional(Type.String({ description: 'Only tools whose name or description contains this text.' })),
|
|
402
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: 'Where to continue a long list (nextOffset from the previous page).' }))
|
|
403
|
+
}),
|
|
404
|
+
execute: async (input) => {
|
|
405
|
+
const listed = await this.list(session)
|
|
406
|
+
const wanted = typeof input.tool === 'string' && input.tool ? input.tool : undefined
|
|
407
|
+
if (wanted) return JSON.stringify({ ...listed, tools: compactTools(listed.tools, wanted) })
|
|
408
|
+
const page = pageTools(listed.tools, { query: typeof input.query === 'string' ? input.query : undefined, offset: typeof input.offset === 'number' ? input.offset : undefined })
|
|
409
|
+
return JSON.stringify({ ...listed, ...page, note: `Names and summaries only. Call mcp_list_tools with tool for the input schema before the first call to that tool.${page.more ? ' More tools match: pass offset, and query to narrow the list.' : ''}` })
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: 'mcp_call_tool',
|
|
414
|
+
label: 'Use connected app',
|
|
415
|
+
description: 'Invoke an enabled app or connected-app tool returned by mcp_list_tools. Current settings are checked on every call. Changes require approval.',
|
|
416
|
+
parameters: (Type) => Type.Object({ name: Type.String(), arguments: Type.Record(Type.String(), Type.Unknown()) }),
|
|
417
|
+
execute: async (input) => JSON.stringify(await this.invoke(session, String(input.name), (input.arguments ?? {}) as Record<string, unknown>))
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---- What a person does on the Connections screen ---------------------------------------------
|
|
423
|
+
|
|
424
|
+
async save(input: Parameters<Connections['save']>[0]): Promise<Connection> {
|
|
425
|
+
const connection = await this.connections.save(input)
|
|
426
|
+
this.cancelConnection(connection.id)
|
|
427
|
+
return connection
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async remove(connectionId: string): Promise<void> {
|
|
431
|
+
await this.connections.remove(connectionId)
|
|
432
|
+
this.cancelConnection(connectionId)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private cancelConnection(connectionId: string): void {
|
|
436
|
+
for (const [key, proposal] of this.proposals) if (proposal.connectionId === connectionId) this.proposals.delete(key)
|
|
437
|
+
void this.manager.close(connectionId)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Connects once and records what the server offers, or why it could not be reached. */
|
|
441
|
+
async test(connectionId: string): Promise<Connection> {
|
|
442
|
+
const connection = this.connections.find(connectionId)
|
|
443
|
+
let probed: Awaited<ReturnType<McpManager['probe']>> | null = null
|
|
444
|
+
let failure: unknown = null
|
|
445
|
+
try {
|
|
446
|
+
probed = await this.manager.probe(await this.connections.launch(connection))
|
|
447
|
+
} catch (error) {
|
|
448
|
+
failure = error
|
|
449
|
+
}
|
|
450
|
+
if (JSON.stringify(this.connections.find(connectionId)) !== JSON.stringify(connection)) throw new HarnessError('mcp_config_changed', 'Connection changed during its test. Test the saved connection again.')
|
|
451
|
+
const updated = this.connections.update(connectionId, (item) => {
|
|
452
|
+
item.checkedAt = now()
|
|
453
|
+
if (probed) {
|
|
454
|
+
item.serverName = probed.serverName
|
|
455
|
+
item.tools = probed.tools
|
|
456
|
+
item.error = null
|
|
457
|
+
if (item.kind === 'workspace' && !item.expectedVaultId) item.expectedVaultId = probed.vaultId
|
|
458
|
+
} else {
|
|
459
|
+
if (isSignInRequired(failure) && item.auth === 'oauth' && item.authState === 'signed_in') item.authState = 'expired'
|
|
460
|
+
item.error = failure instanceof Error ? failure.message : String(failure)
|
|
461
|
+
item.tools = []
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
this.connections.changed()
|
|
465
|
+
return updated
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Signs in with the provider in a browser. Only a person starts this; a session that finds an expired sign-in reports it. */
|
|
469
|
+
async signIn(connectionId: string): Promise<Connection> {
|
|
470
|
+
const connection = this.connections.find(connectionId)
|
|
471
|
+
if (connection.kind !== 'remote' || connection.auth !== 'oauth') throw invalid('This connection does not use browser sign-in.')
|
|
472
|
+
if (connection.oauthClient === 'user' && !(await this.connections.hasOauthClient(connection.id))) throw invalid('Add your OAuth client ID before signing in.')
|
|
473
|
+
await signIn(this.oauth, (await this.connections.launch(connection)) as HttpConfig)
|
|
474
|
+
const updated = this.connections.update(connectionId, (item) => Object.assign(item, { authState: 'signed_in', error: null }))
|
|
475
|
+
await this.manager.close(connectionId)
|
|
476
|
+
this.connections.changed()
|
|
477
|
+
return updated
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async signOut(connectionId: string): Promise<Connection> {
|
|
481
|
+
const updated = await this.connections.signOut(connectionId)
|
|
482
|
+
this.cancelConnection(connectionId)
|
|
483
|
+
return updated
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Reads one retrieved source again from the Xingular Workspace connection it came from. */
|
|
487
|
+
async refreshSource(runId: string, traceId: string, sourceUri: string): Promise<unknown> {
|
|
488
|
+
const trace = this.runContext(runId).find((entry) => entry.id === traceId)
|
|
489
|
+
if (!trace) throw notFound('Retrieved context')
|
|
490
|
+
const source = trace.sources.find((entry) => entry.uri === sourceUri)
|
|
491
|
+
if (!source) throw notFound('Source')
|
|
492
|
+
const connection = this.connections.list().find((item) => item.enabled && item.kind === 'workspace' && item.id === trace.connectionId)
|
|
493
|
+
if (!connection) throw new HarnessError('mcp_access_denied', 'Enable the original Xingular Workspace connection to refresh this source.')
|
|
494
|
+
const kind = sourceUri.split('/')[3] ?? ''
|
|
495
|
+
const [tool, args]: [string, Record<string, unknown>] = kind.startsWith('note')
|
|
496
|
+
? ['x_workspace_note_read', { path: source.path ?? source.id }]
|
|
497
|
+
: kind.startsWith('task')
|
|
498
|
+
? ['x_workspace_task_get', { id: source.id }]
|
|
499
|
+
: kind.startsWith('project')
|
|
500
|
+
? ['x_workspace_project_get', { id: source.id }]
|
|
501
|
+
: kind.startsWith('resource')
|
|
502
|
+
? ['x_workspace_resource_get', { id: source.id }]
|
|
503
|
+
: ['x_workspace_context', { query: source.title || source.id }]
|
|
504
|
+
return this.connections.scrub(await this.manager.readSource(await this.connections.launch(connection), tool, args), connection.id)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Gives one agent exactly these connections, if its creator may reach each of them. */
|
|
508
|
+
setAgentScope(creator: { agentId?: string | null; projectId?: string | null }, agentId: string, ids: readonly string[]): Connection[] {
|
|
509
|
+
this.connections.updateAll((all) => {
|
|
510
|
+
for (const id of ids) {
|
|
511
|
+
const connection = all.find((item) => item.id === id)
|
|
512
|
+
if (!connection) throw notFound('Connection')
|
|
513
|
+
if (!allowed(connection, creator)) throw new HarnessError('required_connections_unavailable', `${connection.name} is disabled or outside your connection scope.`)
|
|
514
|
+
}
|
|
515
|
+
for (const connection of all) {
|
|
516
|
+
connection.agentIds = connection.agentIds.filter((id) => id !== agentId)
|
|
517
|
+
connection.excludedAgentIds = connection.excludedAgentIds.filter((id) => id !== agentId)
|
|
518
|
+
if (ids.includes(connection.id)) {
|
|
519
|
+
if (!connection.global) connection.agentIds.push(agentId)
|
|
520
|
+
} else if (connection.global) {
|
|
521
|
+
connection.excludedAgentIds.push(agentId)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
return this.connections.forScope({ agentId })
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* A call with no session and nobody to ask, made by a plugin's hook. It reaches a connection
|
|
530
|
+
* only when it is on for everyone, and a tool only when a person marked it to run without asking.
|
|
531
|
+
*/
|
|
532
|
+
async callUnattended(reference: string, tool: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
533
|
+
const connection = this.connections.list().find((item) => item.id === reference || item.name === reference)
|
|
534
|
+
if (!connection || !allowed(connection, {})) throw new HarnessError('mcp_access_denied', `There is no connected app called "${reference}" that is on for everyone.`)
|
|
535
|
+
// A change to the first-party workspace is previewed and approved by a person, never run from a hook.
|
|
536
|
+
if (!connection.readTools.includes(tool) || tool.startsWith('x_workspace_mutation')) {
|
|
537
|
+
throw new HarnessError('mcp_denied', `${connection.name} asks before running "${tool}". Mark it to run without asking in the connection's settings to let a plugin use it.`)
|
|
538
|
+
}
|
|
539
|
+
const client = await this.manager.client(UNATTENDED_SCOPE, await this.connections.launch(connection))
|
|
540
|
+
return client.callTool({ name: tool, arguments: args }, { timeout: 60000 })
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Switches one saved connection on or off for everyone, one agent, or one project. */
|
|
544
|
+
attach(connectionId: string, scope: { agentId?: string | null; projectId?: string | null }, on: boolean): void {
|
|
545
|
+
this.connections.updateAll((all) => {
|
|
546
|
+
const connection = all.find((item) => item.id === connectionId)
|
|
547
|
+
if (!connection) throw notFound('Connection')
|
|
548
|
+
if (!connection.enabled) throw invalid(`${connection.name} is switched off. Switch it on in its own settings first.`)
|
|
549
|
+
setScope(connection, scope, on)
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Forgets an agent that no longer exists. */
|
|
554
|
+
removeAgentScope(agentId: string): void {
|
|
555
|
+
this.connections.updateAll((all) => {
|
|
556
|
+
for (const connection of all) {
|
|
557
|
+
connection.agentIds = connection.agentIds.filter((id) => id !== agentId)
|
|
558
|
+
connection.excludedAgentIds = connection.excludedAgentIds.filter((id) => id !== agentId)
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async close(): Promise<void> {
|
|
564
|
+
await this.manager.close(undefined, UNATTENDED_SCOPE).catch(() => undefined)
|
|
565
|
+
await this.manager.close()
|
|
566
|
+
}
|
|
567
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Connected apps: MCP servers the user added. A connection is device-local; its secrets
|
|
2
|
+
// (environment values, request headers, OAuth tokens and client) are in the secret store,
|
|
3
|
+
// never in `connections.json`.
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
/** Loopback address every OAuth sign-in returns to. Pre-registered clients must list it exactly. */
|
|
7
|
+
export const MCP_OAUTH_PORT = 47823
|
|
8
|
+
export const MCP_OAUTH_REDIRECT_URL = `http://127.0.0.1:${MCP_OAUTH_PORT}/oauth/callback`
|
|
9
|
+
|
|
10
|
+
export const connectionSchema = z.object({
|
|
11
|
+
id: z.string().default(''),
|
|
12
|
+
name: z.string().default(''),
|
|
13
|
+
/** `workspace` (the first-party Xingular Workspace server), `local` (a command), or `remote` (Streamable HTTP). */
|
|
14
|
+
kind: z.string().default(''),
|
|
15
|
+
command: z.string().default(''),
|
|
16
|
+
args: z.array(z.string()).default([]),
|
|
17
|
+
cwd: z.string().nullable().default(null),
|
|
18
|
+
envKeys: z.array(z.string()).default([]),
|
|
19
|
+
enabled: z.boolean().default(false),
|
|
20
|
+
/** Available to every agent and project, except those excluded. */
|
|
21
|
+
global: z.boolean().default(false),
|
|
22
|
+
agentIds: z.array(z.string()).default([]),
|
|
23
|
+
projectIds: z.array(z.string()).default([]),
|
|
24
|
+
excludedAgentIds: z.array(z.string()).default([]),
|
|
25
|
+
excludedProjectIds: z.array(z.string()).default([]),
|
|
26
|
+
/** Tools that run without asking. */
|
|
27
|
+
readTools: z.array(z.string()).default([]),
|
|
28
|
+
expectedVaultId: z.string().nullable().default(null),
|
|
29
|
+
checkedAt: z.string().nullable().default(null),
|
|
30
|
+
serverName: z.string().nullable().default(null),
|
|
31
|
+
tools: z.array(z.unknown()).default([]),
|
|
32
|
+
error: z.string().nullable().default(null),
|
|
33
|
+
url: z.string().nullable().default(null),
|
|
34
|
+
/** `none`, `headers`, or `oauth`. */
|
|
35
|
+
auth: z.string().default(''),
|
|
36
|
+
headerKeys: z.array(z.string()).default([]),
|
|
37
|
+
oauthClient: z.string().nullable().default(null),
|
|
38
|
+
oauthScopes: z.array(z.string()).default([]),
|
|
39
|
+
authState: z.string().nullable().default(null),
|
|
40
|
+
catalogId: z.string().nullable().default(null)
|
|
41
|
+
})
|
|
42
|
+
export type Connection = z.infer<typeof connectionSchema>
|
|
43
|
+
|
|
44
|
+
export const connectionInputSchema = connectionSchema.extend({
|
|
45
|
+
env: z.record(z.string(), z.string()).nullish(),
|
|
46
|
+
removeEnv: z.array(z.string()).nullish(),
|
|
47
|
+
headers: z.record(z.string(), z.string()).nullish(),
|
|
48
|
+
removeHeaders: z.array(z.string()).nullish(),
|
|
49
|
+
/** A client registered by the user with the provider; replaces any saved sign-in. */
|
|
50
|
+
oauthCredentials: z.object({ clientId: z.string(), clientSecret: z.string() }).nullish()
|
|
51
|
+
})
|
|
52
|
+
export type ConnectionInput = z.input<typeof connectionInputSchema>
|
|
53
|
+
|
|
54
|
+
export interface StdioConfig {
|
|
55
|
+
id: string
|
|
56
|
+
name: string
|
|
57
|
+
transport: 'stdio'
|
|
58
|
+
command: string
|
|
59
|
+
args: string[]
|
|
60
|
+
cwd: string | null
|
|
61
|
+
env: Record<string, string>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface HttpConfig {
|
|
65
|
+
id: string
|
|
66
|
+
name: string
|
|
67
|
+
transport: 'http'
|
|
68
|
+
url: string
|
|
69
|
+
headers: Record<string, string>
|
|
70
|
+
auth: 'none' | 'headers' | 'oauth'
|
|
71
|
+
scopes: string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What it takes to reach a server: a process to start, or an address and its credentials. */
|
|
75
|
+
export type LaunchConfig = StdioConfig | HttpConfig
|
|
76
|
+
|
|
77
|
+
/** Who a session is, for deciding which connections it may reach. */
|
|
78
|
+
export interface Scope {
|
|
79
|
+
agentId?: string | null
|
|
80
|
+
projectId?: string | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface McpSource {
|
|
84
|
+
id: string
|
|
85
|
+
title: string
|
|
86
|
+
uri: string
|
|
87
|
+
path?: string | null
|
|
88
|
+
revision?: string
|
|
89
|
+
excerpt?: string
|
|
90
|
+
projectId?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One connected-app call a run made, kept in `runs/<id>/mcp-context.json`. */
|
|
94
|
+
export interface McpTrace {
|
|
95
|
+
id: string
|
|
96
|
+
connectionId: string
|
|
97
|
+
connectionName: string
|
|
98
|
+
tool: string
|
|
99
|
+
at: string
|
|
100
|
+
ok: boolean
|
|
101
|
+
summary: string
|
|
102
|
+
result: string
|
|
103
|
+
sources: McpSource[]
|
|
104
|
+
truncated: boolean
|
|
105
|
+
}
|