@opengeni/core 3.0.0-canary.0 → 3.0.1-canary.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/dist/application/connect-operation.d.ts +3 -0
- package/dist/domain/connector-tool-permissions.d.ts +27 -0
- package/dist/domain/knowledge-files.d.ts +1 -1
- package/dist/domain/knowledge-messages.d.ts +2 -2
- package/dist/domain/knowledge-preparation.d.ts +9 -0
- package/dist/domain/knowledge-preparation.test.d.ts +1 -0
- package/dist/domain/organization-integration-catalog.d.ts +15 -0
- package/dist/domain/pr-review.d.ts +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +666 -60
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/application/connect-operation.ts +32 -3
- package/src/domain/capabilities.ts +93 -1
- package/src/domain/connector-tool-permissions.ts +297 -0
- package/src/domain/knowledge-files.ts +10 -3
- package/src/domain/knowledge-messages.ts +1 -0
- package/src/domain/knowledge-preparation.test.ts +251 -0
- package/src/domain/knowledge-preparation.ts +126 -0
- package/src/domain/organization-integration-catalog.ts +83 -0
- package/src/domain/product-integration-pack.ts +1 -1
- package/src/domain/product-integration-skill.gen.ts +6 -2
- package/src/domain/resources.ts +8 -1
- package/src/domain/session-tool-policy.ts +12 -4
- package/src/domain/sessions.ts +165 -34
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1-canary.0",
|
|
4
4
|
"description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -58,17 +58,17 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
61
|
-
"@opengeni/capabilities": "^0.3.
|
|
62
|
-
"@opengeni/codex": "^0.2.23-canary.
|
|
63
|
-
"@opengeni/config": "^1.2.
|
|
64
|
-
"@opengeni/contracts": "^4.
|
|
65
|
-
"@opengeni/db": "^5.0.
|
|
66
|
-
"@opengeni/documents": "^0.8.
|
|
67
|
-
"@opengeni/events": "^0.4.
|
|
68
|
-
"@opengeni/network": "^0.3.1-canary.
|
|
69
|
-
"@opengeni/observability": "^0.8.
|
|
70
|
-
"@opengeni/runtime": "^3.0.
|
|
71
|
-
"@opengeni/storage": "^0.2.
|
|
61
|
+
"@opengeni/capabilities": "^0.3.4-canary.0",
|
|
62
|
+
"@opengeni/codex": "^0.2.23-canary.2",
|
|
63
|
+
"@opengeni/config": "^1.2.2-canary.0",
|
|
64
|
+
"@opengeni/contracts": "^4.1.0-canary.0",
|
|
65
|
+
"@opengeni/db": "^5.0.1-canary.0",
|
|
66
|
+
"@opengeni/documents": "^0.8.30-canary.0",
|
|
67
|
+
"@opengeni/events": "^0.4.28-canary.0",
|
|
68
|
+
"@opengeni/network": "^0.3.1-canary.17",
|
|
69
|
+
"@opengeni/observability": "^0.8.29-canary.0",
|
|
70
|
+
"@opengeni/runtime": "^3.0.1-canary.0",
|
|
71
|
+
"@opengeni/storage": "^0.2.130-canary.0",
|
|
72
72
|
"hono": "^4.12.18",
|
|
73
73
|
"zod": "^4.2.1"
|
|
74
74
|
},
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ConnectAttempt } from "@opengeni/contracts/connect";
|
|
2
|
+
import { assertOrganizationIntegrationAllowed } from "@opengeni/contracts";
|
|
3
|
+
import { withOrganizationIntegrationPolicyFence } from "@opengeni/db/organization-integration-policy";
|
|
4
|
+
import { integrationKeyForConnectProvider } from "../domain/organization-integration-catalog";
|
|
2
5
|
import {
|
|
3
6
|
claimConnectOperation,
|
|
4
7
|
finishConnectOperation,
|
|
@@ -31,11 +34,14 @@ export async function executeConnectOperation(input: {
|
|
|
31
34
|
operationId: string;
|
|
32
35
|
/** Caller computes a stable keyed digest if input contains low-entropy secrets. */
|
|
33
36
|
inputDigest: string;
|
|
37
|
+
/** Trusted server adapter classification, never derived from request data.
|
|
38
|
+
* Cancellation releases pending setup; it acquires no integration authority. */
|
|
39
|
+
purpose?: "acquisition" | "cancellation";
|
|
34
40
|
authorize: ConnectOperationAuthorization;
|
|
35
41
|
execute: (attempt: ConnectAttempt) => Promise<PreparedConnectOperation>;
|
|
36
42
|
}): Promise<ConnectAttempt> {
|
|
37
43
|
// Snapshot caller-controlled identity before the first asynchronous boundary.
|
|
38
|
-
const { db, authorize, execute } = input;
|
|
44
|
+
const { db, authorize, execute, purpose = "acquisition" } = input;
|
|
39
45
|
const scope = { ...input.scope };
|
|
40
46
|
const operation = {
|
|
41
47
|
attemptId: input.attemptId,
|
|
@@ -44,8 +50,31 @@ export async function executeConnectOperation(input: {
|
|
|
44
50
|
inputDigest: input.inputDigest,
|
|
45
51
|
authorize,
|
|
46
52
|
};
|
|
47
|
-
const claim = await
|
|
53
|
+
const claim = await withOrganizationIntegrationPolicyFence(db, scope, async (tx, policy) =>
|
|
54
|
+
claimConnectOperation(tx, scope, {
|
|
55
|
+
...operation,
|
|
56
|
+
authorizeAcquisition: async (_tx, attempt) => {
|
|
57
|
+
if (purpose === "cancellation") return;
|
|
58
|
+
assertOrganizationIntegrationAllowed(
|
|
59
|
+
policy,
|
|
60
|
+
integrationKeyForConnectProvider(attempt.providerId),
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
48
65
|
if (claim.status === "replayed") return claim.attempt;
|
|
49
66
|
const prepared = await execute(structuredClone(claim.attempt));
|
|
50
|
-
return
|
|
67
|
+
return withOrganizationIntegrationPolicyFence(db, scope, async (tx, policy) =>
|
|
68
|
+
finishConnectOperation(tx, scope, {
|
|
69
|
+
...operation,
|
|
70
|
+
commit: prepared.commit,
|
|
71
|
+
authorizeAcquisition: async (_tx, attempt) => {
|
|
72
|
+
if (purpose === "cancellation") return;
|
|
73
|
+
assertOrganizationIntegrationAllowed(
|
|
74
|
+
policy,
|
|
75
|
+
integrationKeyForConnectProvider(attempt.providerId),
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
51
80
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
2
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
3
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { withLockedCapabilityInstallation } from "@opengeni/db/capability-reconciliation";
|
|
4
6
|
import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
|
|
5
7
|
import {
|
|
6
8
|
CapabilityCatalogItem,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
type McpServerConnectionRef,
|
|
18
20
|
type McpPersonalConnectionDelegation,
|
|
19
21
|
OPENGENI_PERSONAL_SLACK_MCP_URL,
|
|
22
|
+
assertOrganizationIntegrationAllowed,
|
|
20
23
|
type SocialConnection,
|
|
21
24
|
} from "@opengeni/contracts";
|
|
22
25
|
import {
|
|
@@ -53,6 +56,10 @@ import {
|
|
|
53
56
|
type InstalledSkillSummary,
|
|
54
57
|
} from "@opengeni/db";
|
|
55
58
|
import { HTTPException } from "hono/http-exception";
|
|
59
|
+
import {
|
|
60
|
+
withOrganizationIntegrationAcquisition,
|
|
61
|
+
withOrganizationIntegrationPolicyFence,
|
|
62
|
+
} from "@opengeni/db/organization-integration-policy";
|
|
56
63
|
import { hasPermission } from "../access";
|
|
57
64
|
import { isFikenConnection, preferredFikenConnection } from "./fiken";
|
|
58
65
|
import { listSkillLibraryEntries, type SkillLibraryEntry } from "@opengeni/runtime/skill-library";
|
|
@@ -252,6 +259,7 @@ export async function enableCapability(
|
|
|
252
259
|
/** Probe outside a durable Connect commit; persist the exact prepared settings
|
|
253
260
|
* inside the caller's authorized receipt transaction. Native enable uses this too. */
|
|
254
261
|
export async function prepareCapabilityEnable(input: EnableCapabilityInput) {
|
|
262
|
+
input = { ...input, payload: structuredClone(input.payload) };
|
|
255
263
|
const item = await requireCatalogItem(
|
|
256
264
|
input.db,
|
|
257
265
|
input.workspaceId,
|
|
@@ -297,6 +305,40 @@ export async function prepareCapabilityEnable(input: EnableCapabilityInput) {
|
|
|
297
305
|
delete installationConfig.headerNames;
|
|
298
306
|
delete installationConfig.connectionRef;
|
|
299
307
|
if (item.kind === "mcp") {
|
|
308
|
+
// This generic, potentially workspace-authored catalog is not proof of a
|
|
309
|
+
// curated provider identity. Dedicated Connect adapters classify separately.
|
|
310
|
+
const unchanged = await withOrganizationIntegrationPolicyFence(
|
|
311
|
+
input.db,
|
|
312
|
+
input,
|
|
313
|
+
async (tx, policy) => {
|
|
314
|
+
const existing = await unchangedMcpInstallation(
|
|
315
|
+
{ ...input, db: tx },
|
|
316
|
+
item,
|
|
317
|
+
installationConfig,
|
|
318
|
+
);
|
|
319
|
+
if (existing) return existing;
|
|
320
|
+
assertOrganizationIntegrationAllowed(policy, "custom:mcp");
|
|
321
|
+
return null;
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
if (unchanged) {
|
|
325
|
+
return {
|
|
326
|
+
commit: (db: Database) =>
|
|
327
|
+
withOrganizationIntegrationPolicyFence(db, input, async (tx) => {
|
|
328
|
+
const current = await unchangedMcpInstallation(
|
|
329
|
+
{ ...input, db: tx },
|
|
330
|
+
item,
|
|
331
|
+
installationConfig,
|
|
332
|
+
);
|
|
333
|
+
if (!current || !isDeepStrictEqual(current, unchanged)) {
|
|
334
|
+
throw new HTTPException(409, {
|
|
335
|
+
message: "Capability installation changed; reload before reconciling",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return current;
|
|
339
|
+
}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
300
342
|
const headers = await resolveMcpCredentialHeaders(input, item);
|
|
301
343
|
const connectionRef = input.payload.connectionRef
|
|
302
344
|
? await validateMcpCapabilityConnectionRef(input, item, input.payload.connectionRef)
|
|
@@ -326,7 +368,57 @@ export async function prepareCapabilityEnable(input: EnableCapabilityInput) {
|
|
|
326
368
|
config: installationConfig,
|
|
327
369
|
metadata: installationMetadata,
|
|
328
370
|
};
|
|
329
|
-
return {
|
|
371
|
+
return {
|
|
372
|
+
commit: (db: Database) =>
|
|
373
|
+
item.kind === "mcp"
|
|
374
|
+
? withOrganizationIntegrationAcquisition(db, installation, ["custom:mcp"], (tx) =>
|
|
375
|
+
enableCapabilityInstallation(tx, installation),
|
|
376
|
+
)
|
|
377
|
+
: enableCapabilityInstallation(db, installation),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** A no-effect reconciliation is not a new acquisition. Validate ordinary
|
|
382
|
+
* connection visibility before locking the installation, and compare actual
|
|
383
|
+
* stored credentials rather than trusting caller-supplied identity hints. */
|
|
384
|
+
async function unchangedMcpInstallation(
|
|
385
|
+
input: EnableCapabilityInput,
|
|
386
|
+
item: CapabilityCatalogItem,
|
|
387
|
+
requestedConfig: Record<string, unknown>,
|
|
388
|
+
): Promise<CapabilityInstallation | null> {
|
|
389
|
+
const connectionRef = input.payload.connectionRef
|
|
390
|
+
? await validateMcpCapabilityConnectionRef(input, item, input.payload.connectionRef)
|
|
391
|
+
: null;
|
|
392
|
+
const existing = await withLockedCapabilityInstallation(
|
|
393
|
+
input.db,
|
|
394
|
+
input.workspaceId,
|
|
395
|
+
item.id,
|
|
396
|
+
(tx) => getCapabilityInstallation(tx, input.workspaceId, item.id),
|
|
397
|
+
);
|
|
398
|
+
if (!existing || existing.kind !== "mcp" || existing.status !== "active") return null;
|
|
399
|
+
const {
|
|
400
|
+
headerNames: _names,
|
|
401
|
+
headersEncrypted: _encrypted,
|
|
402
|
+
headers: _headers,
|
|
403
|
+
...storedConfig
|
|
404
|
+
} = existing.config;
|
|
405
|
+
const config = { ...requestedConfig, ...(connectionRef ? { connectionRef } : {}) };
|
|
406
|
+
const { mcpConnectivity: _storedConnectivity, ...storedMetadata } = existing.metadata;
|
|
407
|
+
const { mcpConnectivity: _requestedConnectivity, ...requestedMetadata } = input.payload.metadata;
|
|
408
|
+
if (
|
|
409
|
+
!isDeepStrictEqual(config, storedConfig) ||
|
|
410
|
+
!isDeepStrictEqual(requestedMetadata, storedMetadata)
|
|
411
|
+
)
|
|
412
|
+
return null;
|
|
413
|
+
const provided = normalizedMcpCredentialHeaders(input.payload.headers);
|
|
414
|
+
if (provided) {
|
|
415
|
+
const stored = await resolveMcpCredentialHeaders(
|
|
416
|
+
{ ...input, payload: { ...input.payload, headers: {} } },
|
|
417
|
+
item,
|
|
418
|
+
);
|
|
419
|
+
if (!isDeepStrictEqual(provided, stored)) return null;
|
|
420
|
+
}
|
|
421
|
+
return existing;
|
|
330
422
|
}
|
|
331
423
|
|
|
332
424
|
/**
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
5
|
+
import {
|
|
6
|
+
boundMcpResponseBody,
|
|
7
|
+
MCP_MAX_RESPONSE_BYTES,
|
|
8
|
+
assertMcpToolListWithinBounds,
|
|
9
|
+
} from "@opengeni/runtime/mcp-network";
|
|
10
|
+
import { createPinnedIntegrationTransport } from "@opengeni/capabilities";
|
|
11
|
+
import type { Settings } from "@opengeni/config";
|
|
12
|
+
import type {
|
|
13
|
+
AccessGrant,
|
|
14
|
+
ConnectorToolPermission,
|
|
15
|
+
ConnectorToolPermissionEntry,
|
|
16
|
+
ConnectorToolPermissionsResponse,
|
|
17
|
+
UpdateConnectorToolPermissionsRequest,
|
|
18
|
+
} from "@opengeni/contracts";
|
|
19
|
+
import {
|
|
20
|
+
buildConnectionTokenResolver,
|
|
21
|
+
getConnectionMetadata,
|
|
22
|
+
listConnectionsMetadata,
|
|
23
|
+
listEnabledMcpCapabilityServers,
|
|
24
|
+
listConnectorToolPermissionPolicies,
|
|
25
|
+
updateConnectorToolPermissionPolicies,
|
|
26
|
+
resolveConnectorActionPolicy,
|
|
27
|
+
type Database,
|
|
28
|
+
} from "@opengeni/db";
|
|
29
|
+
import { HTTPException } from "hono/http-exception";
|
|
30
|
+
import { hasPermission } from "../access";
|
|
31
|
+
import { buildCapabilityCatalog, settingsWithMcpCapabilityServers } from "./capabilities";
|
|
32
|
+
|
|
33
|
+
type Input = {
|
|
34
|
+
db: Database;
|
|
35
|
+
settings: Settings;
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
grant: AccessGrant;
|
|
38
|
+
capabilityId: string;
|
|
39
|
+
personalOwnerVerified: boolean;
|
|
40
|
+
};
|
|
41
|
+
type ListedTool = {
|
|
42
|
+
name: string;
|
|
43
|
+
title?: string | undefined;
|
|
44
|
+
description?: string | undefined;
|
|
45
|
+
annotations?:
|
|
46
|
+
| {
|
|
47
|
+
readOnlyHint?: boolean | undefined;
|
|
48
|
+
destructiveHint?: boolean | undefined;
|
|
49
|
+
title?: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
| undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function connectorToolGroup(tool: ListedTool): "read" | "write" | "other" {
|
|
55
|
+
if (tool.annotations?.destructiveHint === true || tool.annotations?.readOnlyHint === false)
|
|
56
|
+
return "write";
|
|
57
|
+
if (tool.annotations?.readOnlyHint === true) return "read";
|
|
58
|
+
return "other";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function resolveTarget(input: Input) {
|
|
62
|
+
const catalog = await buildCapabilityCatalog(input);
|
|
63
|
+
const item = catalog.items.find((candidate) => candidate.id === input.capabilityId);
|
|
64
|
+
if (
|
|
65
|
+
!item ||
|
|
66
|
+
!item.enabled ||
|
|
67
|
+
item.kind !== "mcp" ||
|
|
68
|
+
!item.runtime.mcpServerId ||
|
|
69
|
+
item.source === "built_in"
|
|
70
|
+
) {
|
|
71
|
+
throw new HTTPException(404, { message: "Connected MCP connector not found" });
|
|
72
|
+
}
|
|
73
|
+
const enabled = await listEnabledMcpCapabilityServers(input.db, input.workspaceId);
|
|
74
|
+
const settings = settingsWithMcpCapabilityServers(input.settings, enabled);
|
|
75
|
+
const server = settings.mcpServers.find((candidate) => candidate.id === item.runtime.mcpServerId);
|
|
76
|
+
if (!server || server.url !== item.endpointUrl)
|
|
77
|
+
throw new HTTPException(409, { message: "Connector configuration is unavailable" });
|
|
78
|
+
const ref = server.connectionRef;
|
|
79
|
+
if (ref?.authoritySource === "host")
|
|
80
|
+
throw new HTTPException(409, {
|
|
81
|
+
message: "Tool permissions for this connection are managed by its host",
|
|
82
|
+
});
|
|
83
|
+
let connection = ref?.connectionId
|
|
84
|
+
? await getConnectionMetadata(
|
|
85
|
+
input.db,
|
|
86
|
+
input.workspaceId,
|
|
87
|
+
ref.connectionId,
|
|
88
|
+
input.grant.subjectId,
|
|
89
|
+
)
|
|
90
|
+
: null;
|
|
91
|
+
if (ref?.subjectScope === "subject" && !ref.connectionId) {
|
|
92
|
+
const visible = await listConnectionsMetadata(
|
|
93
|
+
input.db,
|
|
94
|
+
input.workspaceId,
|
|
95
|
+
input.grant.subjectId,
|
|
96
|
+
);
|
|
97
|
+
connection =
|
|
98
|
+
visible.find(
|
|
99
|
+
(candidate) =>
|
|
100
|
+
candidate.subjectId === input.grant.subjectId &&
|
|
101
|
+
candidate.providerDomain === ref.providerDomain &&
|
|
102
|
+
(!ref.kind || candidate.kind === ref.kind) &&
|
|
103
|
+
candidate.status === "active",
|
|
104
|
+
) ?? null;
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
ref &&
|
|
108
|
+
(!connection ||
|
|
109
|
+
connection.providerDomain !== ref.providerDomain ||
|
|
110
|
+
(ref.kind && connection.kind !== ref.kind) ||
|
|
111
|
+
(ref.subjectScope === "subject"
|
|
112
|
+
? connection.subjectId !== input.grant.subjectId
|
|
113
|
+
: connection.subjectId !== null))
|
|
114
|
+
) {
|
|
115
|
+
throw new HTTPException(409, { message: "Reconnect this connector to manage its tools" });
|
|
116
|
+
}
|
|
117
|
+
if (connection?.subjectId && !input.personalOwnerVerified)
|
|
118
|
+
throw new HTTPException(403, {
|
|
119
|
+
message: "Only the authenticated connection owner may manage personal tool permissions",
|
|
120
|
+
});
|
|
121
|
+
// Same secret-free target identity used by runtime sessionMcpApprovalConnectionId.
|
|
122
|
+
const connectionId =
|
|
123
|
+
connection?.id ??
|
|
124
|
+
`session-mcp:${server.id}:${createHash("sha256").update(server.url, "utf8").digest("hex")}`;
|
|
125
|
+
return { server, connection, connectionId };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function listTools(
|
|
129
|
+
input: Input,
|
|
130
|
+
target: Awaited<ReturnType<typeof resolveTarget>>,
|
|
131
|
+
): Promise<ListedTool[]> {
|
|
132
|
+
let headers = { ...target.server.headers };
|
|
133
|
+
if (target.server.connectionRef) {
|
|
134
|
+
const result = await buildConnectionTokenResolver(
|
|
135
|
+
input.db,
|
|
136
|
+
input.settings,
|
|
137
|
+
)({
|
|
138
|
+
workspaceId: input.workspaceId,
|
|
139
|
+
...(target.connection?.subjectId ? { subjectId: input.grant.subjectId } : {}),
|
|
140
|
+
serverId: target.server.id,
|
|
141
|
+
toolName: "tools/list",
|
|
142
|
+
connectionRef: { ...target.server.connectionRef, connectionId: target.connectionId },
|
|
143
|
+
destinationUrl: target.server.url,
|
|
144
|
+
});
|
|
145
|
+
if (result.status !== "ok") throw new Error("Reconnect this connector to load its tools.");
|
|
146
|
+
headers = { ...headers, ...result.headers };
|
|
147
|
+
}
|
|
148
|
+
const pinned = createPinnedIntegrationTransport({ network: input.settings });
|
|
149
|
+
const deadline = AbortSignal.timeout(15_000);
|
|
150
|
+
const client = new Client(
|
|
151
|
+
{ name: "opengeni-tool-permissions", version: "0.1.0" },
|
|
152
|
+
{ capabilities: {} },
|
|
153
|
+
);
|
|
154
|
+
try {
|
|
155
|
+
const transport = new StreamableHTTPClientTransport(new URL(target.server.url), {
|
|
156
|
+
requestInit: { headers },
|
|
157
|
+
fetch: async (url, init) =>
|
|
158
|
+
boundMcpResponseBody(
|
|
159
|
+
await pinned.fetch(url.toString(), {
|
|
160
|
+
...init,
|
|
161
|
+
signal: init?.signal ? AbortSignal.any([deadline, init.signal]) : deadline,
|
|
162
|
+
}),
|
|
163
|
+
MCP_MAX_RESPONSE_BYTES,
|
|
164
|
+
),
|
|
165
|
+
});
|
|
166
|
+
await client.connect(transport as unknown as Transport, {
|
|
167
|
+
timeout: 15_000,
|
|
168
|
+
maxTotalTimeout: 15_000,
|
|
169
|
+
});
|
|
170
|
+
const tools: ListedTool[] = [];
|
|
171
|
+
const cursors = new Set<string>();
|
|
172
|
+
let cursor: string | undefined;
|
|
173
|
+
do {
|
|
174
|
+
const page = await client.listTools(cursor ? { cursor } : undefined, {
|
|
175
|
+
timeout: 15_000,
|
|
176
|
+
maxTotalTimeout: 15_000,
|
|
177
|
+
signal: deadline,
|
|
178
|
+
});
|
|
179
|
+
tools.push(...page.tools);
|
|
180
|
+
assertMcpToolListWithinBounds(tools);
|
|
181
|
+
if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
|
|
182
|
+
throw new Error("The connector returned duplicate tool names.");
|
|
183
|
+
cursor = page.nextCursor;
|
|
184
|
+
if (tools.length > 2048 || (cursor && cursors.has(cursor)))
|
|
185
|
+
throw new Error("The connector returned an invalid or oversized tool catalog.");
|
|
186
|
+
if (cursor) cursors.add(cursor);
|
|
187
|
+
} while (cursor);
|
|
188
|
+
return tools.filter(
|
|
189
|
+
(tool) => !target.server.allowedTools || target.server.allowedTools.includes(tool.name),
|
|
190
|
+
);
|
|
191
|
+
} finally {
|
|
192
|
+
await client.close().catch(() => undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function getConnectorToolPermissions(
|
|
197
|
+
input: Input,
|
|
198
|
+
): Promise<ConnectorToolPermissionsResponse> {
|
|
199
|
+
const target = await resolveTarget(input);
|
|
200
|
+
const policies = await listConnectorToolPermissionPolicies(input.db, {
|
|
201
|
+
accountId: input.grant.accountId,
|
|
202
|
+
workspaceId: input.workspaceId,
|
|
203
|
+
connectionId: target.connectionId,
|
|
204
|
+
});
|
|
205
|
+
const permission = (
|
|
206
|
+
name: string,
|
|
207
|
+
): { permission: ConnectorToolPermission; inherited: boolean } => {
|
|
208
|
+
const resolved = resolveConnectorActionPolicy(policies, {
|
|
209
|
+
connectionId: target.connectionId,
|
|
210
|
+
serverId: target.server.id,
|
|
211
|
+
toolName: name,
|
|
212
|
+
actionName: "*",
|
|
213
|
+
});
|
|
214
|
+
if (!resolved.managed) return { permission: "allow", inherited: true };
|
|
215
|
+
return {
|
|
216
|
+
permission: resolved.entry?.policy ?? "block",
|
|
217
|
+
inherited: resolved.entry?.toolName !== name,
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
const defaultPolicy = policies.find(
|
|
221
|
+
(row) => row.serverId === target.server.id && row.toolName === "*" && row.actionName === "*",
|
|
222
|
+
);
|
|
223
|
+
let tools: ListedTool[] = [];
|
|
224
|
+
let discoveryError: string | null = null;
|
|
225
|
+
try {
|
|
226
|
+
tools = await listTools(input, target);
|
|
227
|
+
if (tools.some((tool) => tool.name === "*" || tool.name !== tool.name.trim())) {
|
|
228
|
+
tools = tools.filter((tool) => tool.name !== "*" && tool.name === tool.name.trim());
|
|
229
|
+
discoveryError =
|
|
230
|
+
"The connector exposes a reserved or whitespace-padded tool name. Individual permissions for these names are unsupported, so they are omitted from tool groups. The connector default still applies.";
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
discoveryError =
|
|
234
|
+
"Could not load the connector's tools. Try again or reconnect. Your saved permissions still apply.";
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
connectionId: target.connectionId,
|
|
238
|
+
serverId: target.server.id,
|
|
239
|
+
defaultPermission: defaultPolicy?.policy ?? null,
|
|
240
|
+
tools: tools.map(
|
|
241
|
+
(tool): ConnectorToolPermissionEntry => ({
|
|
242
|
+
name: tool.name,
|
|
243
|
+
...((tool.title ?? tool.annotations?.title)
|
|
244
|
+
? { title: tool.title ?? tool.annotations?.title }
|
|
245
|
+
: {}),
|
|
246
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
247
|
+
group: connectorToolGroup(tool),
|
|
248
|
+
...permission(tool.name),
|
|
249
|
+
approvalRequired:
|
|
250
|
+
target.server.requireApproval === true ||
|
|
251
|
+
(Array.isArray(target.server.requireApproval) &&
|
|
252
|
+
target.server.requireApproval.includes(tool.name)),
|
|
253
|
+
}),
|
|
254
|
+
),
|
|
255
|
+
discoveryError,
|
|
256
|
+
canManage: canManageConnectorPermissions(input.grant),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function canManageConnectorPermissions(grant: AccessGrant): boolean {
|
|
261
|
+
return (
|
|
262
|
+
hasPermission(grant.permissions, "capabilities:manage") &&
|
|
263
|
+
grant.principalKind !== "agent_attempt" &&
|
|
264
|
+
grant.principalKind !== "service" &&
|
|
265
|
+
!grant.serviceInitiator &&
|
|
266
|
+
!grant.serviceInitiatorContext
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function updateConnectorToolPermissions(
|
|
271
|
+
input: Input & { payload: UpdateConnectorToolPermissionsRequest },
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
if (!canManageConnectorPermissions(input.grant))
|
|
274
|
+
throw new HTTPException(403, { message: "Connector management permission required" });
|
|
275
|
+
if (
|
|
276
|
+
input.payload.target === "tools" &&
|
|
277
|
+
input.payload.toolNames.some((name) => name === "*" || name !== name.trim())
|
|
278
|
+
)
|
|
279
|
+
throw new HTTPException(400, {
|
|
280
|
+
message: "Tool names must be exact and cannot use the connector-default wildcard.",
|
|
281
|
+
});
|
|
282
|
+
const target = await resolveTarget(input);
|
|
283
|
+
// Prevent a reconnect while the sheet is open from applying old choices to a new account.
|
|
284
|
+
if (target.connectionId !== input.payload.connectionId)
|
|
285
|
+
throw new HTTPException(409, {
|
|
286
|
+
message: "The connector account changed. Reload its permissions.",
|
|
287
|
+
});
|
|
288
|
+
await updateConnectorToolPermissionPolicies(input.db, {
|
|
289
|
+
accountId: input.grant.accountId,
|
|
290
|
+
workspaceId: input.workspaceId,
|
|
291
|
+
subjectId: input.grant.subjectId,
|
|
292
|
+
connectionId: target.connectionId,
|
|
293
|
+
serverId: target.server.id,
|
|
294
|
+
toolNames: input.payload.target === "default" ? ["*"] : input.payload.toolNames,
|
|
295
|
+
policy: input.payload.permission,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -12,6 +12,7 @@ export async function prepareKnowledgeFile(
|
|
|
12
12
|
deps: Pick<ApiRouteDeps, "db" | "objectStorage" | "getDocumentServices">,
|
|
13
13
|
context: KnowledgeContext,
|
|
14
14
|
fileId: string,
|
|
15
|
+
purpose: "evidence" | "reference" = "evidence",
|
|
15
16
|
) {
|
|
16
17
|
const inspected = await inspectKnowledgeFilePreparation(deps.db, context, fileId);
|
|
17
18
|
if (inspected.status !== "prepare") return inspected;
|
|
@@ -27,13 +28,19 @@ export async function prepareKnowledgeFile(
|
|
|
27
28
|
) {
|
|
28
29
|
throw new Error("The original file no longer matches its retained metadata");
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
31
|
+
// A screenshot/diagram can support a visual finding without containing text.
|
|
32
|
+
// Keep its exact original; OCR is neither required evidence nor the finding.
|
|
33
|
+
const originalOnly = purpose === "evidence" && inspected.file.contentType.startsWith("image/");
|
|
34
|
+
const parsed = originalOnly
|
|
35
|
+
? { text: "" }
|
|
36
|
+
: await deps.getDocumentServices().parser.parse(object.bytes, inspected.file);
|
|
37
|
+
if (!originalOnly && !parsed.text.trim())
|
|
38
|
+
throw new Error("No searchable text could be extracted from this file");
|
|
33
39
|
return completeKnowledgeFilePreparation(deps.db, context, {
|
|
34
40
|
fileId,
|
|
35
41
|
title: inspected.file.filename,
|
|
36
42
|
sourceVersion,
|
|
37
43
|
content: parsed.text,
|
|
44
|
+
purpose,
|
|
38
45
|
});
|
|
39
46
|
}
|