@harborclient/team-hub 0.2.3 → 0.3.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/cli.js CHANGED
@@ -25,6 +25,35 @@ import path from "path";
25
25
  import { parse as parseYaml } from "yaml";
26
26
 
27
27
  // src/config/llmConfig.ts
28
+ function headerRowsFromSingleKeyObject(record) {
29
+ const rows = [];
30
+ for (const [key, value] of Object.entries(record)) {
31
+ const trimmedKey = key.trim();
32
+ if (trimmedKey.length > 0) {
33
+ rows.push({ key: trimmedKey, value: String(value) });
34
+ }
35
+ }
36
+ return rows;
37
+ }
38
+ function normalizeHubMcpHeaders(headers) {
39
+ if (!headers) {
40
+ return [];
41
+ }
42
+ if (!Array.isArray(headers)) {
43
+ return headerRowsFromSingleKeyObject(headers);
44
+ }
45
+ const rows = [];
46
+ for (const item of headers) {
47
+ if (Array.isArray(item)) {
48
+ for (const nested of item) {
49
+ rows.push(...headerRowsFromSingleKeyObject(nested));
50
+ }
51
+ continue;
52
+ }
53
+ rows.push(...headerRowsFromSingleKeyObject(item));
54
+ }
55
+ return rows;
56
+ }
28
57
  function normalizeLlmConfig(section) {
29
58
  const providers = {};
30
59
  if (section.providers.openai?.apiKey) {
@@ -36,9 +65,15 @@ function normalizeLlmConfig(section) {
36
65
  if (section.providers.gemini?.apiKey) {
37
66
  providers.gemini = { apiKey: section.providers.gemini.apiKey };
38
67
  }
68
+ const mcp = section.mcp && section.mcp.length > 0 ? section.mcp.map((entry) => ({
69
+ name: entry.name.trim(),
70
+ url: entry.url.trim().replace(/\/+$/, ""),
71
+ headers: normalizeHubMcpHeaders(entry.headers)
72
+ })) : void 0;
39
73
  return {
40
74
  providers,
41
- ...section.models && section.models.length > 0 ? { models: section.models } : {}
75
+ ...section.models && section.models.length > 0 ? { models: section.models } : {},
76
+ ...mcp && mcp.length > 0 ? { mcp } : {}
42
77
  };
43
78
  }
44
79
 
@@ -116,6 +151,15 @@ var redisSectionSchema = z.object({
116
151
  var llmProviderEntrySchema = z.object({
117
152
  apiKey: z.string().trim().min(1, { message: "LLM provider apiKey must not be empty." })
118
153
  });
154
+ var hubMcpHeadersSchema = z.union([
155
+ z.record(z.string(), z.string()),
156
+ z.array(z.union([z.record(z.string(), z.string()), z.array(z.record(z.string(), z.string()))]))
157
+ ]);
158
+ var hubMcpServerEntrySchema = z.object({
159
+ name: z.string().trim().min(1),
160
+ url: z.string().trim().min(1),
161
+ headers: hubMcpHeadersSchema.optional()
162
+ });
119
163
  var llmSectionSchema = z.object({
120
164
  providers: z.object({
121
165
  openai: llmProviderEntrySchema.optional(),
@@ -125,7 +169,8 @@ var llmSectionSchema = z.object({
125
169
  (providers) => Boolean(providers.openai?.apiKey || providers.claude?.apiKey || providers.gemini?.apiKey),
126
170
  { message: "llm.providers must include at least one provider with an apiKey." }
127
171
  ),
128
- models: z.array(z.string().trim().min(1)).optional()
172
+ models: z.array(z.string().trim().min(1)).optional(),
173
+ mcp: z.array(hubMcpServerEntrySchema).optional()
129
174
  });
130
175
  var pluginsSectionSchema = z.object({
131
176
  catalogs: z.array(z.string().trim().url()).optional(),
@@ -6043,6 +6088,12 @@ function canAccessEnvironment(user, environmentId) {
6043
6088
  }
6044
6089
  return user.environmentAccess.includes(environmentId);
6045
6090
  }
6091
+ function canDeleteCollection(user, collection) {
6092
+ return canUseDataApi(user) && canAccessCollection(user, collection.id) && collection.createdByUserId === user.id && !collection.deletionLocked;
6093
+ }
6094
+ function canDeleteRequest(user, request) {
6095
+ return canUseDataApi(user) && canAccessCollection(user, request.collectionId) && request.createdByUserId === user.id;
6096
+ }
6046
6097
  function canCreateCollection(user) {
6047
6098
  return user.role === "user" && hasWildcardAccess(user.collectionAccess);
6048
6099
  }
@@ -6718,6 +6769,80 @@ async function registerAdminRoutes(app, options) {
6718
6769
  }
6719
6770
  }
6720
6771
  });
6772
+ routes.route({
6773
+ method: "GET",
6774
+ url: "/admin/collections/:collectionId/folders",
6775
+ schema: {
6776
+ params: collectionIdParamSchema,
6777
+ response: {
6778
+ 200: listFoldersResponseSchema,
6779
+ 403: errorResponseSchema,
6780
+ 404: errorResponseSchema
6781
+ }
6782
+ },
6783
+ /**
6784
+ * Lists folders in a collection for operator inspection.
6785
+ */
6786
+ handler: async (request, reply) => {
6787
+ try {
6788
+ const user = requireAuthenticatedUser(request);
6789
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
6790
+ return;
6791
+ }
6792
+ const collection = await db.findCollectionById(request.params.collectionId);
6793
+ if (!collection) {
6794
+ void reply.code(404).send({ error: "Collection not found" });
6795
+ return;
6796
+ }
6797
+ const folders = await db.listFolders(request.params.collectionId);
6798
+ return reply.send({
6799
+ folders: folders.map((folder) => serializeFolder(folder))
6800
+ });
6801
+ } catch (error) {
6802
+ if (handleDbError(reply, error)) {
6803
+ return;
6804
+ }
6805
+ throw error;
6806
+ }
6807
+ }
6808
+ });
6809
+ routes.route({
6810
+ method: "GET",
6811
+ url: "/admin/collections/:collectionId/requests",
6812
+ schema: {
6813
+ params: collectionIdParamSchema,
6814
+ response: {
6815
+ 200: listRequestsResponseSchema,
6816
+ 403: errorResponseSchema,
6817
+ 404: errorResponseSchema
6818
+ }
6819
+ },
6820
+ /**
6821
+ * Lists saved requests in a collection for operator inspection.
6822
+ */
6823
+ handler: async (request, reply) => {
6824
+ try {
6825
+ const user = requireAuthenticatedUser(request);
6826
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
6827
+ return;
6828
+ }
6829
+ const collection = await db.findCollectionById(request.params.collectionId);
6830
+ if (!collection) {
6831
+ void reply.code(404).send({ error: "Collection not found" });
6832
+ return;
6833
+ }
6834
+ const requests = await db.listRequests(request.params.collectionId);
6835
+ return reply.send({
6836
+ requests: requests.map((savedRequest) => serializeSavedRequest(savedRequest))
6837
+ });
6838
+ } catch (error) {
6839
+ if (handleDbError(reply, error)) {
6840
+ return;
6841
+ }
6842
+ throw error;
6843
+ }
6844
+ }
6845
+ });
6721
6846
  routes.route({
6722
6847
  method: "GET",
6723
6848
  url: "/admin/environments",
@@ -6787,6 +6912,41 @@ async function registerAdminRoutes(app, options) {
6787
6912
  }
6788
6913
  }
6789
6914
  });
6915
+ routes.route({
6916
+ method: "DELETE",
6917
+ url: "/admin/requests/:id",
6918
+ schema: {
6919
+ params: idParamSchema,
6920
+ response: {
6921
+ 204: emptyResponseSchema,
6922
+ 403: errorResponseSchema,
6923
+ 404: errorResponseSchema
6924
+ }
6925
+ },
6926
+ /**
6927
+ * Deletes a saved request regardless of collection access lists.
6928
+ */
6929
+ handler: async (request, reply) => {
6930
+ try {
6931
+ const user = requireAuthenticatedUser(request);
6932
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
6933
+ return;
6934
+ }
6935
+ const existingRequest = await db.findRequestById(request.params.id);
6936
+ if (!existingRequest) {
6937
+ void reply.code(404).send({ error: "Request not found" });
6938
+ return;
6939
+ }
6940
+ await db.deleteRequest(request.params.id, user.id);
6941
+ return reply.code(204).send(null);
6942
+ } catch (error) {
6943
+ if (handleDbError(reply, error)) {
6944
+ return;
6945
+ }
6946
+ throw error;
6947
+ }
6948
+ }
6949
+ });
6790
6950
  routes.route({
6791
6951
  method: "PUT",
6792
6952
  url: "/admin/collections/:id",
@@ -7350,12 +7510,6 @@ async function registerCollectionRoutes(app, db) {
7350
7510
  handler: async (request, reply) => {
7351
7511
  try {
7352
7512
  const user = requireAuthenticatedUser(request);
7353
- if (denyUnlessAllowed(
7354
- reply,
7355
- canUseDataApi(user) && canAccessCollection(user, request.params.id)
7356
- )) {
7357
- return;
7358
- }
7359
7513
  const collection = await db.findCollectionById(request.params.id);
7360
7514
  if (!collection) {
7361
7515
  void reply.code(404).send({ error: "Collection not found" });
@@ -7364,6 +7518,9 @@ async function registerCollectionRoutes(app, db) {
7364
7518
  if (collection.deletionLocked) {
7365
7519
  throw new DeletionLockedError("collection");
7366
7520
  }
7521
+ if (denyUnlessAllowed(reply, canDeleteCollection(user, collection))) {
7522
+ return;
7523
+ }
7367
7524
  await db.deleteCollection(request.params.id, user.id);
7368
7525
  return reply.code(204).send(null);
7369
7526
  } catch (error) {
@@ -7892,10 +8049,7 @@ async function registerRequestRoutes(app, db) {
7892
8049
  if (!existingRequest) {
7893
8050
  return reply.code(404).send({ error: "Request not found" });
7894
8051
  }
7895
- if (denyUnlessAllowed(
7896
- reply,
7897
- canUseDataApi(user) && canAccessCollection(user, existingRequest.collectionId)
7898
- )) {
8052
+ if (denyUnlessAllowed(reply, canDeleteRequest(user, existingRequest))) {
7899
8053
  return;
7900
8054
  }
7901
8055
  await db.deleteRequest(request.params.id, user.id);
@@ -8107,6 +8261,258 @@ async function runLlmCompletion(config, input) {
8107
8261
  return parseCompletionResponse(json);
8108
8262
  }
8109
8263
 
8264
+ // src/server/llm/hubMcpToolNames.ts
8265
+ var HUB_MCP_TOOL_PREFIX = "hubmcp__";
8266
+ function encodeHubMcpToolName(serverIndex, toolName) {
8267
+ return `${HUB_MCP_TOOL_PREFIX}${serverIndex}__${toolName}`;
8268
+ }
8269
+ function decodeHubMcpToolName(prefixed) {
8270
+ if (!prefixed.startsWith(HUB_MCP_TOOL_PREFIX)) {
8271
+ return null;
8272
+ }
8273
+ const rest = prefixed.slice(HUB_MCP_TOOL_PREFIX.length);
8274
+ const separatorIndex = rest.indexOf("__");
8275
+ if (separatorIndex <= 0) {
8276
+ return null;
8277
+ }
8278
+ const serverIndex = Number.parseInt(rest.slice(0, separatorIndex), 10);
8279
+ if (!Number.isInteger(serverIndex) || serverIndex < 0) {
8280
+ return null;
8281
+ }
8282
+ const toolName = rest.slice(separatorIndex + 2);
8283
+ if (!toolName) {
8284
+ return null;
8285
+ }
8286
+ return { serverIndex, toolName };
8287
+ }
8288
+ function isHubMcpToolName(name) {
8289
+ return decodeHubMcpToolName(name) !== null;
8290
+ }
8291
+
8292
+ // src/server/llm/mcpClient.ts
8293
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8294
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
8295
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8296
+ var connectedClients = /* @__PURE__ */ new Map();
8297
+ var activeConfigSignature = "";
8298
+ function buildRequestHeaders(headers) {
8299
+ const result = {};
8300
+ for (const row of headers) {
8301
+ if (row.key.trim()) {
8302
+ result[row.key.trim()] = row.value;
8303
+ }
8304
+ }
8305
+ return result;
8306
+ }
8307
+ function buildMcpConfigSignature(config) {
8308
+ return JSON.stringify(config.mcp ?? []);
8309
+ }
8310
+ function flattenMcpToolContent(content) {
8311
+ if (!Array.isArray(content)) {
8312
+ return JSON.stringify(content ?? null);
8313
+ }
8314
+ const parts = content.map((item) => {
8315
+ if (!item || typeof item !== "object") {
8316
+ return String(item);
8317
+ }
8318
+ const record = item;
8319
+ if (record.type === "text" && typeof record.text === "string") {
8320
+ return record.text;
8321
+ }
8322
+ if (record.type === "resource" && typeof record.uri === "string") {
8323
+ return record.uri;
8324
+ }
8325
+ return JSON.stringify(record);
8326
+ });
8327
+ return parts.join("\n");
8328
+ }
8329
+ function toOpenAiTool(serverIndex, tool) {
8330
+ return {
8331
+ type: "function",
8332
+ function: {
8333
+ name: encodeHubMcpToolName(serverIndex, tool.name),
8334
+ description: tool.description ?? `MCP tool ${tool.name}`,
8335
+ parameters: tool.inputSchema ?? {
8336
+ type: "object",
8337
+ properties: {},
8338
+ additionalProperties: true
8339
+ }
8340
+ }
8341
+ };
8342
+ }
8343
+ async function connectHubMcpServer(serverIndex, server) {
8344
+ const headers = buildRequestHeaders(server.headers);
8345
+ const url = new URL(server.url);
8346
+ const client = new Client(
8347
+ {
8348
+ name: "team-hub",
8349
+ version: "1.0.0"
8350
+ },
8351
+ {}
8352
+ );
8353
+ let transport;
8354
+ try {
8355
+ transport = new StreamableHTTPClientTransport(url, {
8356
+ requestInit: { headers }
8357
+ });
8358
+ await client.connect(transport);
8359
+ } catch {
8360
+ transport = new SSEClientTransport(url, {
8361
+ requestInit: { headers }
8362
+ });
8363
+ await client.connect(transport);
8364
+ }
8365
+ const { tools } = await client.listTools();
8366
+ return {
8367
+ serverIndex,
8368
+ client,
8369
+ remoteTools: tools
8370
+ };
8371
+ }
8372
+ async function ensureHubMcpConnections(config) {
8373
+ const servers = config.mcp ?? [];
8374
+ const signature = buildMcpConfigSignature(config);
8375
+ if (signature !== activeConfigSignature) {
8376
+ await disposeHubMcpConnections();
8377
+ activeConfigSignature = signature;
8378
+ }
8379
+ if (servers.length === 0) {
8380
+ return;
8381
+ }
8382
+ for (const [index, server] of servers.entries()) {
8383
+ if (connectedClients.has(index)) {
8384
+ continue;
8385
+ }
8386
+ try {
8387
+ const connected = await connectHubMcpServer(index, server);
8388
+ connectedClients.set(index, connected);
8389
+ } catch (error) {
8390
+ const message = error instanceof Error ? error.message : "Connection failed";
8391
+ console.warn(`Hub MCP server "${server.name}" failed to connect: ${message}`);
8392
+ }
8393
+ }
8394
+ }
8395
+ function listHubMcpTools() {
8396
+ const tools = [];
8397
+ for (const entry of connectedClients.values()) {
8398
+ for (const tool of entry.remoteTools) {
8399
+ tools.push(toOpenAiTool(entry.serverIndex, tool));
8400
+ }
8401
+ }
8402
+ return tools;
8403
+ }
8404
+ async function callHubMcpTool(prefixedName, args) {
8405
+ const decoded = decodeHubMcpToolName(prefixedName);
8406
+ if (!decoded) {
8407
+ return JSON.stringify({ error: `Unknown hub MCP tool: ${prefixedName}` });
8408
+ }
8409
+ const entry = connectedClients.get(decoded.serverIndex);
8410
+ if (!entry) {
8411
+ return JSON.stringify({ error: `Hub MCP server ${decoded.serverIndex} is not connected.` });
8412
+ }
8413
+ try {
8414
+ const result = await entry.client.callTool({
8415
+ name: decoded.toolName,
8416
+ arguments: args ?? {}
8417
+ });
8418
+ if (result.isError) {
8419
+ return JSON.stringify({
8420
+ error: flattenMcpToolContent(result.content)
8421
+ });
8422
+ }
8423
+ return flattenMcpToolContent(result.content);
8424
+ } catch (error) {
8425
+ const message = error instanceof Error ? error.message : "MCP tool execution failed.";
8426
+ return JSON.stringify({ error: message });
8427
+ }
8428
+ }
8429
+ async function disposeHubMcpConnections() {
8430
+ const closePromises = [...connectedClients.values()].map(async (entry) => {
8431
+ try {
8432
+ await entry.client.close();
8433
+ } catch {
8434
+ }
8435
+ });
8436
+ connectedClients.clear();
8437
+ activeConfigSignature = "";
8438
+ await Promise.allSettled(closePromises);
8439
+ }
8440
+
8441
+ // src/server/llm/agent.ts
8442
+ var HUB_CHAT_STEP_MAX_ITERATIONS = 8;
8443
+ function addUsage(current, next) {
8444
+ return {
8445
+ promptTokens: current.promptTokens + next.promptTokens,
8446
+ completionTokens: current.completionTokens + next.completionTokens,
8447
+ totalTokens: current.totalTokens + next.totalTokens
8448
+ };
8449
+ }
8450
+ function parseToolArguments(raw) {
8451
+ try {
8452
+ return JSON.parse(raw);
8453
+ } catch {
8454
+ return {};
8455
+ }
8456
+ }
8457
+ async function runHubChatStep(config, input, deps = {}) {
8458
+ const runCompletion = deps.runCompletion ?? runLlmCompletion;
8459
+ const ensureConnections = deps.ensureConnections ?? ensureHubMcpConnections;
8460
+ const listTools = deps.listTools ?? listHubMcpTools;
8461
+ const callTool = deps.callTool ?? callHubMcpTool;
8462
+ await ensureConnections(config);
8463
+ const hubTools = listTools();
8464
+ const mergedTools = hubTools.length > 0 || (input.tools?.length ?? 0) > 0 ? [...hubTools, ...input.tools ?? []] : void 0;
8465
+ let messages = [...input.messages];
8466
+ let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
8467
+ let lastContent = null;
8468
+ for (let iteration = 0; iteration < HUB_CHAT_STEP_MAX_ITERATIONS; iteration += 1) {
8469
+ const result = await runCompletion(config, {
8470
+ model: input.model,
8471
+ messages,
8472
+ systemPrompt: input.systemPrompt,
8473
+ tools: mergedTools
8474
+ });
8475
+ usage = addUsage(usage, result.usage);
8476
+ lastContent = result.content;
8477
+ const toolCalls = result.toolCalls ?? [];
8478
+ if (toolCalls.length === 0) {
8479
+ return {
8480
+ content: result.content,
8481
+ usage
8482
+ };
8483
+ }
8484
+ const hubCalls = toolCalls.filter((call) => isHubMcpToolName(call.name));
8485
+ const passthroughCalls = toolCalls.filter((call) => !isHubMcpToolName(call.name));
8486
+ if (passthroughCalls.length > 0) {
8487
+ return {
8488
+ content: result.content,
8489
+ toolCalls: passthroughCalls,
8490
+ usage
8491
+ };
8492
+ }
8493
+ messages = [
8494
+ ...messages,
8495
+ {
8496
+ role: "assistant",
8497
+ content: result.content,
8498
+ tool_calls: hubCalls
8499
+ }
8500
+ ];
8501
+ for (const call of hubCalls) {
8502
+ const toolResult = await callTool(call.name, parseToolArguments(call.arguments));
8503
+ messages.push({
8504
+ role: "tool",
8505
+ tool_call_id: call.id,
8506
+ content: toolResult
8507
+ });
8508
+ }
8509
+ }
8510
+ return {
8511
+ content: lastContent,
8512
+ usage
8513
+ };
8514
+ }
8515
+
8110
8516
  // src/server/routes/llm.ts
8111
8517
  function sendMonthlyLimitExceeded(reply) {
8112
8518
  return reply.code(402).send({
@@ -8224,7 +8630,7 @@ async function registerLlmRoutes(app, options) {
8224
8630
  if (isNewTurn && isOverMonthlyLimit(totalTokens, user.llmMonthlyTokenLimit)) {
8225
8631
  return sendMonthlyLimitExceeded(reply);
8226
8632
  }
8227
- const result = await runLlmCompletion(llm, {
8633
+ const result = await runHubChatStep(llm, {
8228
8634
  model,
8229
8635
  messages,
8230
8636
  tools,
@@ -8716,6 +9122,7 @@ function registerGracefulShutdown(app, ctx) {
8716
9122
  const shutdown = async (signal) => {
8717
9123
  app.log.info(`Received ${signal}, shutting down.`);
8718
9124
  await app.close();
9125
+ await disposeHubMcpConnections();
8719
9126
  await disconnectAll(ctx);
8720
9127
  process.exit(0);
8721
9128
  };