@opengeni/capabilities 0.1.1

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/src/http.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { pinnedFetch, readResponseBodyBounded, type FetchLike } from "@opengeni/network";
2
+
3
+ import type { IntegrationTransport, PinnedIntegrationTransportOptions } from "./types";
4
+ import { IntegrationInvocationError } from "./types";
5
+
6
+ export const DEFAULT_INTEGRATION_TIMEOUT_MS = 30_000;
7
+ export const DEFAULT_INTEGRATION_RESPONSE_BYTES = 4 * 1024 * 1024;
8
+ export const MAX_INTEGRATION_SPEC_BYTES = 8 * 1024 * 1024;
9
+ export const MAX_INTEGRATION_TOOLS = 2_000;
10
+
11
+ export async function fetchIntegrationSourceDocument(
12
+ transport: IntegrationTransport,
13
+ sourceUrl: string,
14
+ maxBytes = MAX_INTEGRATION_SPEC_BYTES,
15
+ ): Promise<Uint8Array> {
16
+ const url = new URL(sourceUrl);
17
+ const response = await fetchWithDeadline(
18
+ transport,
19
+ url,
20
+ {
21
+ method: "GET",
22
+ headers: { accept: "application/json, application/yaml, text/yaml, */*;q=0.5" },
23
+ },
24
+ DEFAULT_INTEGRATION_TIMEOUT_MS,
25
+ );
26
+ if (response.status >= 300 && response.status < 400) {
27
+ await response.body?.cancel().catch(() => undefined);
28
+ throw new IntegrationInvocationError(
29
+ "source_redirect_rejected",
30
+ "Integration source attempted to redirect",
31
+ "failed",
32
+ false,
33
+ response.status,
34
+ );
35
+ }
36
+ if (!response.ok) {
37
+ await response.body?.cancel().catch(() => undefined);
38
+ throw new IntegrationInvocationError(
39
+ "source_fetch_rejected",
40
+ "Integration source could not be read",
41
+ "failed",
42
+ response.status >= 500,
43
+ response.status,
44
+ );
45
+ }
46
+ return await readResponseBodyBounded(response, maxBytes, "Integration source");
47
+ }
48
+
49
+ export function createPinnedIntegrationTransport(
50
+ options: PinnedIntegrationTransportOptions,
51
+ ): IntegrationTransport {
52
+ return {
53
+ fetch: (input, init) =>
54
+ pinnedFetch(input, init, options.network, {
55
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
56
+ label: "Integration request",
57
+ requireHttpsOutsideLocalTest: true,
58
+ }),
59
+ };
60
+ }
61
+
62
+ export function directIntegrationTransport(fetchImpl: FetchLike): IntegrationTransport {
63
+ return { fetch: fetchImpl };
64
+ }
65
+
66
+ export async function fetchWithDeadline(
67
+ transport: IntegrationTransport,
68
+ url: URL,
69
+ init: RequestInit,
70
+ timeoutMs = DEFAULT_INTEGRATION_TIMEOUT_MS,
71
+ ): Promise<Response> {
72
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) {
73
+ throw new RangeError("integration timeout must be between 1 and 120000 milliseconds");
74
+ }
75
+ const controller = new AbortController();
76
+ const onAbort = () => controller.abort(init.signal?.reason);
77
+ if (init.signal?.aborted) onAbort();
78
+ else init.signal?.addEventListener("abort", onAbort, { once: true });
79
+ const timer = setTimeout(
80
+ () => controller.abort(new Error("integration request timed out")),
81
+ timeoutMs,
82
+ );
83
+ try {
84
+ return await transport.fetch(url, {
85
+ ...init,
86
+ signal: controller.signal,
87
+ redirect: "manual",
88
+ });
89
+ } catch {
90
+ const timedOut = controller.signal.aborted && !init.signal?.aborted;
91
+ throw new IntegrationInvocationError(
92
+ timedOut ? "request_timeout" : "request_failed",
93
+ timedOut ? "Integration request timed out" : "Integration request failed",
94
+ requestCouldHaveStarted(init.method) ? "unknown" : "not_started",
95
+ !requestCouldHaveStarted(init.method),
96
+ );
97
+ } finally {
98
+ clearTimeout(timer);
99
+ init.signal?.removeEventListener("abort", onAbort);
100
+ }
101
+ }
102
+
103
+ function requestCouldHaveStarted(method: string | undefined): boolean {
104
+ const normalized = (method ?? "GET").toUpperCase();
105
+ return normalized !== "GET" && normalized !== "HEAD" && normalized !== "OPTIONS";
106
+ }
107
+
108
+ export async function readIntegrationResponse(
109
+ response: Response,
110
+ maxBytes = DEFAULT_INTEGRATION_RESPONSE_BYTES,
111
+ ): Promise<{ data: unknown; contentType: string; bytes: number }> {
112
+ const body = await readResponseBodyBounded(response, maxBytes, "Integration response");
113
+ const contentType =
114
+ response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
115
+ if (body.byteLength === 0) return { data: null, contentType, bytes: 0 };
116
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(body);
117
+ if (contentType === "application/json" || contentType.endsWith("+json")) {
118
+ try {
119
+ return { data: JSON.parse(text), contentType, bytes: body.byteLength };
120
+ } catch {
121
+ throw new IntegrationInvocationError(
122
+ "response_json_invalid",
123
+ "Integration returned invalid JSON",
124
+ "failed",
125
+ false,
126
+ response.status,
127
+ );
128
+ }
129
+ }
130
+ return { data: text, contentType, bytes: body.byteLength };
131
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./auth";
2
+ export * from "./graphql";
3
+ export * from "./http";
4
+ export * from "./mcp-manifest";
5
+ export * from "./openapi";
6
+ export * from "./providers";
7
+ export * from "./revision";
8
+ export * from "./types";
@@ -0,0 +1,90 @@
1
+ import { stableToolId } from "./revision";
2
+
3
+ export interface McpToolManifestEntry {
4
+ readonly toolId: string;
5
+ readonly toolName: string;
6
+ readonly description: string | null;
7
+ readonly inputSchema?: unknown;
8
+ readonly outputSchema?: unknown;
9
+ readonly annotations?: Readonly<Record<string, unknown>>;
10
+ }
11
+
12
+ export interface McpToolManifest {
13
+ readonly server: {
14
+ readonly name: string | null;
15
+ readonly version: string | null;
16
+ readonly instructions: string | null;
17
+ } | null;
18
+ readonly tools: readonly McpToolManifestEntry[];
19
+ }
20
+
21
+ export function extractMcpToolManifest(
22
+ listToolsResult: unknown,
23
+ metadata: {
24
+ readonly serverInfo?: unknown;
25
+ readonly instructions?: string;
26
+ } = {},
27
+ ): McpToolManifest {
28
+ const listed =
29
+ listToolsResult &&
30
+ typeof listToolsResult === "object" &&
31
+ Array.isArray((listToolsResult as { tools?: unknown }).tools)
32
+ ? (listToolsResult as { tools: unknown[] }).tools
33
+ : [];
34
+ const seen = new Map<string, number>();
35
+ const tools = listed.flatMap((value): McpToolManifestEntry[] => {
36
+ if (!value || typeof value !== "object") return [];
37
+ const tool = value as Record<string, unknown>;
38
+ if (typeof tool.name !== "string" || !tool.name.trim()) return [];
39
+ const toolName = tool.name.trim();
40
+ return [
41
+ {
42
+ toolId: stableToolId(toolName, seen),
43
+ toolName,
44
+ description: typeof tool.description === "string" ? tool.description : null,
45
+ ...(tool.inputSchema !== undefined
46
+ ? { inputSchema: tool.inputSchema }
47
+ : tool.parameters !== undefined
48
+ ? { inputSchema: tool.parameters }
49
+ : {}),
50
+ ...(tool.outputSchema !== undefined ? { outputSchema: tool.outputSchema } : {}),
51
+ ...(tool.annotations && typeof tool.annotations === "object"
52
+ ? { annotations: tool.annotations as Readonly<Record<string, unknown>> }
53
+ : {}),
54
+ },
55
+ ];
56
+ });
57
+ const info =
58
+ metadata.serverInfo && typeof metadata.serverInfo === "object"
59
+ ? (metadata.serverInfo as Record<string, unknown>)
60
+ : null;
61
+ return {
62
+ server: info
63
+ ? {
64
+ name: typeof info.name === "string" ? info.name : null,
65
+ version: typeof info.version === "string" ? info.version : null,
66
+ instructions: metadata.instructions ?? null,
67
+ }
68
+ : null,
69
+ tools,
70
+ };
71
+ }
72
+
73
+ export function deriveMcpNamespace(input: {
74
+ readonly name?: string | null;
75
+ readonly endpoint?: string | null;
76
+ readonly command?: string | null;
77
+ }): string {
78
+ const candidate =
79
+ input.name?.trim() || hostname(input.endpoint) || basename(input.command) || "mcp";
80
+ return stableToolId(candidate);
81
+ }
82
+
83
+ function hostname(value: string | null | undefined): string {
84
+ if (!value || !URL.canParse(value)) return "";
85
+ return new URL(value).hostname;
86
+ }
87
+
88
+ function basename(value: string | null | undefined): string {
89
+ return value?.trim().split(/[\\/]/).pop() ?? "";
90
+ }