@agent-native/core 0.222.0-nightly-20261029004912 → 0.222.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/agent/production-agent.js +22 -9
  2. package/dist/agent/run-store.d.ts +9 -1
  3. package/dist/agent/run-store.js +26 -3
  4. package/dist/cli/code-agent-mcp-config.js +6 -1
  5. package/dist/client/chat/agentkit-agent-native.js +1 -2
  6. package/dist/client/chat/agentkit-protocol.js +14 -0
  7. package/dist/client/chat/runtime.d.ts +5 -1
  8. package/dist/client/chat/runtime.js +23 -1
  9. package/dist/client/sse-event-processor.d.ts +10 -0
  10. package/dist/client/sse-event-processor.js +1 -1
  11. package/dist/collab/routes.d.ts +1 -1
  12. package/dist/collab/struct-routes.d.ts +1 -1
  13. package/dist/connections/catalog.d.ts +1 -1
  14. package/dist/connections/catalog.js +4 -11
  15. package/dist/mcp-client/config.d.ts +6 -0
  16. package/dist/mcp-client/manager.js +25 -2
  17. package/dist/mcp-client/routes.js +2 -0
  18. package/dist/mcp-client/workspace-connection-servers.d.ts +30 -0
  19. package/dist/mcp-client/workspace-connection-servers.js +119 -0
  20. package/dist/notifications/routes.d.ts +3 -3
  21. package/dist/provider-api/actions/custom-provider-registration.d.ts +8 -8
  22. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  23. package/dist/provider-api/actions/provider-api.js +2 -0
  24. package/dist/provider-api/agent-request-policy.d.ts +13 -0
  25. package/dist/provider-api/agent-request-policy.js +31 -0
  26. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  27. package/dist/provider-api/corpus-jobs.js +7 -1
  28. package/dist/provider-api/index.d.ts +4 -1
  29. package/dist/provider-api/index.js +4 -1
  30. package/dist/provider-api/read-only-requests.js +11 -2
  31. package/dist/resources/handlers.d.ts +1 -1
  32. package/dist/secrets/routes.d.ts +4 -4
  33. package/dist/server/action-discovery.js +6 -0
  34. package/dist/server/agent-chat/process-run-failure.js +58 -2
  35. package/dist/server/agent-engine-api-key-route.d.ts +3 -3
  36. package/dist/server/workspace-provider-oauth.d.ts +1 -0
  37. package/dist/server/workspace-provider-oauth.js +10 -2
  38. package/dist/templates/chat/.agents/skills/actions/references/provider-apis.md +13 -0
  39. package/dist/templates/default/.agents/skills/actions/references/provider-apis.md +13 -0
  40. package/dist/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +10 -4
  41. package/dist/templates/factory/.agents/skills/review-latest-feedback/references/slack-reaction-replies.md +287 -0
  42. package/dist/templates/factory/.agents/skills/review-prs/SKILL.md +266 -92
  43. package/dist/templates/headless/.agents/skills/actions/references/provider-apis.md +13 -0
  44. package/dist/templates/workspace-core/.agents/skills/actions/references/provider-apis.md +13 -0
  45. package/dist/vite/action-types-plugin.js +4 -0
  46. package/dist/workspace-connections/actions/list-workspace-connection-scopes.d.ts +16 -0
  47. package/dist/workspace-connections/actions/list-workspace-connection-scopes.js +62 -0
  48. package/package.json +5 -4
  49. package/src/templates/chat/.agents/skills/actions/references/provider-apis.md +13 -0
  50. package/src/templates/default/.agents/skills/actions/references/provider-apis.md +13 -0
  51. package/src/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +10 -4
  52. package/src/templates/factory/.agents/skills/review-latest-feedback/references/slack-reaction-replies.md +287 -0
  53. package/src/templates/factory/.agents/skills/review-prs/SKILL.md +266 -92
  54. package/src/templates/headless/.agents/skills/actions/references/provider-apis.md +13 -0
  55. package/src/templates/workspace-core/.agents/skills/actions/references/provider-apis.md +13 -0
@@ -5417,6 +5417,7 @@ export async function runAgentLoop(opts) {
5417
5417
  const blockedA2ATargets = new Map();
5418
5418
  let finalGuardRetries = 0;
5419
5419
  let emptyFinalResponseRetries = 0;
5420
+ let followUpOnlyTurns = 0;
5420
5421
  let preserveReasoningEffortForEmptyResponseRetries = false;
5421
5422
  let consecutiveMaxTokensContinuations = 0;
5422
5423
  let truncatedToolCallRetries = 0;
@@ -6201,14 +6202,20 @@ export async function runAgentLoop(opts) {
6201
6202
  else {
6202
6203
  consecutiveMaxTokensContinuations = 0;
6203
6204
  }
6204
- if (followUpResults.length > 0 && terminalStopReason === "tool_use") {
6205
- if (streamedAssistantText)
6206
- send({ type: "clear" });
6207
- continue;
6208
- }
6205
+ // Only a follow-up call with no reply asks again. A reply sent beside
6206
+ // its follow-ups is the final answer: clearing it to ask again leaves
6207
+ // the model recapping a reply the user no longer sees.
6208
+ // Past the cap, an empty turn takes the bounded empty-response retries
6209
+ // below instead of asking again until the loop's iteration limit.
6209
6210
  if (followUpResults.length > 0 &&
6210
6211
  hasEmptyFinalResponse &&
6211
- terminalStopReason !== "max_tokens") {
6212
+ terminalStopReason !== "max_tokens" &&
6213
+ followUpOnlyTurns < EMPTY_FINAL_RESPONSE_RETRY_LIMIT) {
6214
+ followUpOnlyTurns += 1;
6215
+ // Whitespace the model streamed before its follow-ups is not a reply,
6216
+ // and the next turn's answer would land after it.
6217
+ if (streamedAssistantText)
6218
+ await send({ type: "clear" });
6212
6219
  flushUnstreamedAssistantText();
6213
6220
  continue;
6214
6221
  }
@@ -6306,7 +6313,7 @@ export async function runAgentLoop(opts) {
6306
6313
  flushUnstreamedAssistantText();
6307
6314
  if (followUpRunId &&
6308
6315
  completedFollowUpSuggestions === undefined &&
6309
- terminalStopReason === "end_turn") {
6316
+ (terminalStopReason === "end_turn" || followUpResults.length > 0)) {
6310
6317
  completingFollowUpSuggestions = true;
6311
6318
  followUpStartedAt = Date.now();
6312
6319
  followUpCompletionContext = buildFollowUpCompletionMessages({
@@ -6328,6 +6335,7 @@ export async function runAgentLoop(opts) {
6328
6335
  }
6329
6336
  finalGuardRetries = 0;
6330
6337
  emptyFinalResponseRetries = 0;
6338
+ followUpOnlyTurns = 0;
6331
6339
  preserveReasoningEffortForEmptyResponseRetries = false;
6332
6340
  consecutiveMaxTokensContinuations = 0;
6333
6341
  const toolCallTruncatedByOutputCap = terminalStopReason === "max_tokens";
@@ -9374,6 +9382,8 @@ export async function chainServerDrivenContinuation(opts) {
9374
9382
  // Approval selectors apply only to the request that consumes their one-shot grants.
9375
9383
  delete continuationBody.approvedToolCalls;
9376
9384
  delete continuationBody.approvedToolCallAskIds;
9385
+ // The initial admission consumed this one-time queued-message claim.
9386
+ delete continuationBody.queuedMessageClaimId;
9377
9387
  delete continuationBody[AGENT_CHAT_BACKGROUND_RUN_FIELD];
9378
9388
  const markRunsAbortedOrThrow = async (...runIds) => {
9379
9389
  const results = await Promise.allSettled(runIds.map(async (targetRunId) => {
@@ -11528,12 +11538,15 @@ export function createProductionAgentHandler(options) {
11528
11538
  let durableDispatchPayload;
11529
11539
  if (dispatchToBackground) {
11530
11540
  try {
11531
- durableDispatchPayload = serializeDurableDispatchPayload({
11541
+ const dispatchBody = {
11532
11542
  ...body,
11533
11543
  ...(Array.isArray(attachments) || requestAttachments.length > 0
11534
11544
  ? { attachments: requestAttachments }
11535
11545
  : {}),
11536
- });
11546
+ };
11547
+ // This one-time claim authorizes only the request that consumes it.
11548
+ delete dispatchBody.queuedMessageClaimId;
11549
+ durableDispatchPayload = serializeDurableDispatchPayload(dispatchBody);
11537
11550
  }
11538
11551
  catch (error) {
11539
11552
  if (!(error instanceof DurableAttachmentReferenceRequiredError)) {
@@ -317,7 +317,15 @@ export declare const RUN_DIAG_STAGE: {
317
317
  readonly staleRunReaped: "stale_run_reaped";
318
318
  };
319
319
  export type RunDiagStage = (typeof RUN_DIAG_STAGE)[keyof typeof RUN_DIAG_STAGE];
320
- export declare function recordRunDiagnostic(runId: string, stage: RunDiagStage, detail?: string): Promise<void>;
320
+ export interface RouteFailureDiagnosticEvidence {
321
+ format: "route_failure_v1";
322
+ errorMetadata?: "unreadable";
323
+ errorName?: string;
324
+ errorCode?: string;
325
+ httpStatus?: string;
326
+ causeName?: string;
327
+ }
328
+ export declare function recordRunDiagnostic(runId: string, stage: RunDiagStage, detail?: string, evidence?: RouteFailureDiagnosticEvidence): Promise<void>;
321
329
  export declare function updateRunHeartbeat(runId: string): Promise<void>;
322
330
  export declare function bumpRunProgress(runId: string): Promise<boolean>;
323
331
  /**
@@ -2058,16 +2058,39 @@ export const RUN_DIAG_STAGE = {
2058
2058
  runBoundaryReached: "run_boundary_reached",
2059
2059
  staleRunReaped: "stale_run_reaped",
2060
2060
  };
2061
- export async function recordRunDiagnostic(runId, stage, detail) {
2061
+ export async function recordRunDiagnostic(runId, stage, detail, evidence) {
2062
2062
  if (!runId)
2063
2063
  return;
2064
2064
  try {
2065
2065
  await ensureRunTables();
2066
- const payload = JSON.stringify({
2066
+ // raw-sql-ok: diagnostic JSON is passed only as a bound SQL value.
2067
+ const payloadFields = {
2067
2068
  stage,
2068
2069
  ...(detail ? { detail: detail.slice(0, 1500) } : {}),
2070
+ ...(stage === RUN_DIAG_STAGE.routeThrew && evidence ? { evidence } : {}),
2069
2071
  at: Date.now(),
2070
- }).slice(0, 2000);
2072
+ };
2073
+ // raw-sql-ok: diagnostic JSON is passed only as a bound SQL value.
2074
+ let payload = JSON.stringify(payloadFields);
2075
+ if (payload.length > 2000 && typeof payloadFields.detail === "string") {
2076
+ const fullDetail = payloadFields.detail;
2077
+ let low = 0;
2078
+ let high = fullDetail.length;
2079
+ while (low < high) {
2080
+ const middle = Math.ceil((low + high) / 2);
2081
+ payloadFields.detail = fullDetail.slice(0, middle);
2082
+ if (JSON.stringify(payloadFields).length <= 2000)
2083
+ low = middle;
2084
+ else
2085
+ high = middle - 1;
2086
+ }
2087
+ if (low === 0)
2088
+ delete payloadFields.detail;
2089
+ else
2090
+ payloadFields.detail = fullDetail.slice(0, low);
2091
+ // raw-sql-ok: diagnostic JSON is passed only as a bound SQL value.
2092
+ payload = JSON.stringify(payloadFields);
2093
+ }
2071
2094
  const isWorkerStage = stage === RUN_DIAG_STAGE.workerSetupStep ||
2072
2095
  stage === RUN_DIAG_STAGE.workerStarted;
2073
2096
  if (isWorkerStage) {
@@ -132,7 +132,12 @@ function codeAgentHttpMcpServers(config, environment) {
132
132
  // its credential headers) silently replace another. Reject that instead.
133
133
  const idsByKey = new Map();
134
134
  return Object.entries(servers).flatMap(([serverId, server]) => {
135
- if (server.type !== "http" || !server.url || server.configurationError)
135
+ // A workspace-connection server's bearer is resolved per request inside
136
+ // this process, so a CLI copy would have no credentials.
137
+ if (server.type !== "http" ||
138
+ !server.url ||
139
+ server.configurationError ||
140
+ server.workspaceConnection)
136
141
  return [];
137
142
  const key = codexConfigKey(serverId);
138
143
  const existing = idsByKey.get(key);
@@ -978,8 +978,6 @@ function reconcileDurableMessages(messages, durable, events, runs, toolCalls) {
978
978
  snapshotReply.id !== durableReply.id &&
979
979
  eventMessageIds?.size === 1 &&
980
980
  eventMessageIds.has(snapshotReply.id) &&
981
- snapshotReply.parts.length > 0 &&
982
- snapshotReply.parts.every((part) => part.type === "text") &&
983
981
  snapshotText.length > 0 &&
984
982
  durableText.length > 0 &&
985
983
  snapshotText !== durableText &&
@@ -4221,6 +4219,7 @@ export function createAgentNativeAgentKitTransport(options = {}) {
4221
4219
  switch (sourceEvent.type) {
4222
4220
  case "message.created":
4223
4221
  case "message.completed":
4222
+ case "message.replaced":
4224
4223
  if (sourceEvent.message.role === "assistant") {
4225
4224
  const messageId = foldedMessageIdFor(sourceEvent.message.id);
4226
4225
  if (messageId && messageId !== sourceEvent.message.id) {
@@ -2026,6 +2026,20 @@ export function createAgentKitProtocolAdapter(runtime, options = {}) {
2026
2026
  },
2027
2027
  ...attachPendingWidgets(event.messageId),
2028
2028
  ];
2029
+ case "message-replace":
2030
+ run.activeMessageId = event.message.id;
2031
+ run.activeMessageRole = event.message.role;
2032
+ run.activeMessageCompleted = false;
2033
+ return [
2034
+ {
2035
+ type: "message.replaced",
2036
+ ...(base.occurredAt === undefined
2037
+ ? {}
2038
+ : { occurredAt: base.occurredAt }),
2039
+ ...(base.metadata === undefined ? {} : { metadata: base.metadata }),
2040
+ message: runtimeMessageToProtocolMessage(event.message, textFormat),
2041
+ },
2042
+ ];
2029
2043
  case "message-done":
2030
2044
  run.activeMessageId = event.message.id;
2031
2045
  run.activeMessageRole = event.message.role;
@@ -307,6 +307,10 @@ export interface AgentChatRuntimeMessageDeltaEvent extends AgentChatRuntimeEvent
307
307
  export interface AgentChatRuntimeMessageDoneEvent extends AgentChatRuntimeEventBase<"message-done"> {
308
308
  readonly message: AgentChatRuntimeMessage;
309
309
  }
310
+ /** Replaces a streaming message's content without finishing it. */
311
+ export interface AgentChatRuntimeMessageReplaceEvent extends AgentChatRuntimeEventBase<"message-replace"> {
312
+ readonly message: AgentChatRuntimeMessage;
313
+ }
310
314
  export interface AgentChatRuntimeToolStartEvent extends AgentChatRuntimeEventBase<"tool-start"> {
311
315
  readonly toolCall: AgentChatRuntimeToolCall;
312
316
  }
@@ -546,7 +550,7 @@ export interface AgentChatRuntimeDoneEvent extends AgentChatRuntimeEventBase<"do
546
550
  export interface AgentChatRuntimeCustomEvent extends AgentChatRuntimeEventBase {
547
551
  readonly [key: string]: unknown;
548
552
  }
549
- export type AgentChatRuntimeKnownEvent = AgentChatRuntimeMessageStartEvent | AgentChatRuntimeMessageDeltaEvent | AgentChatRuntimeMessageDoneEvent | AgentChatRuntimeToolStartEvent | AgentChatRuntimeToolDeltaEvent | AgentChatRuntimeToolDoneEvent | AgentChatRuntimeApprovalRequestEvent | AgentChatRuntimeApprovalResolvedEvent | AgentChatRuntimeConnectionRequestEvent | AgentChatRuntimeStatusEvent | AgentChatRuntimeSuggestionsEvent | AgentChatRuntimeAnnotationEvent | AgentChatRuntimeWidgetEvent | AgentChatRuntimeParticipantEvent | AgentChatRuntimeInteractionEvent | AgentChatRuntimeActivityEvent | AgentChatRuntimeTaskEvent | AgentChatRuntimeTaskGroupEvent | AgentChatRuntimeUploadProgressEvent | AgentChatRuntimeClientEffectEvent | AgentChatRuntimeExtensionEvent | AgentChatRuntimeArtifactEvent | AgentChatRuntimeFileEvent | AgentChatRuntimeUsageEvent | AgentChatRuntimeErrorEvent | AgentChatRuntimeContinuationEvent | AgentChatRuntimeDoneEvent;
553
+ export type AgentChatRuntimeKnownEvent = AgentChatRuntimeMessageStartEvent | AgentChatRuntimeMessageDeltaEvent | AgentChatRuntimeMessageDoneEvent | AgentChatRuntimeMessageReplaceEvent | AgentChatRuntimeToolStartEvent | AgentChatRuntimeToolDeltaEvent | AgentChatRuntimeToolDoneEvent | AgentChatRuntimeApprovalRequestEvent | AgentChatRuntimeApprovalResolvedEvent | AgentChatRuntimeConnectionRequestEvent | AgentChatRuntimeStatusEvent | AgentChatRuntimeSuggestionsEvent | AgentChatRuntimeAnnotationEvent | AgentChatRuntimeWidgetEvent | AgentChatRuntimeParticipantEvent | AgentChatRuntimeInteractionEvent | AgentChatRuntimeActivityEvent | AgentChatRuntimeTaskEvent | AgentChatRuntimeTaskGroupEvent | AgentChatRuntimeUploadProgressEvent | AgentChatRuntimeClientEffectEvent | AgentChatRuntimeExtensionEvent | AgentChatRuntimeArtifactEvent | AgentChatRuntimeFileEvent | AgentChatRuntimeUsageEvent | AgentChatRuntimeErrorEvent | AgentChatRuntimeContinuationEvent | AgentChatRuntimeDoneEvent;
550
554
  export type AgentChatRuntimeEvent<TCustomEvent extends AgentChatRuntimeCustomEvent = never> = AgentChatRuntimeKnownEvent | TCustomEvent;
551
555
  export interface AgentChatRuntimeTurn<TEvent extends AgentChatRuntimeEventBase = AgentChatRuntimeKnownEvent> {
552
556
  readonly id?: AgentChatRuntimeTurnId;
@@ -6,7 +6,7 @@ import { agentEngineStatusUrlForChatApi, requireAgentEngineConfiguredForDispatch
6
6
  import { getOrCreateAnalyticsSessionId } from "../analytics-session.js";
7
7
  import { agentChatStreamingUrl, agentNativePath } from "../api-path.js";
8
8
  import { CHAT_REQUEST_TOO_LARGE_MESSAGE } from "../error-format.js";
9
- import { appendMissingFinalResponseWarning, findApprovalToolCallIndex, } from "../sse-event-processor.js";
9
+ import { appendMissingFinalResponseWarning, clearAssistantDraftContent, findApprovalToolCallIndex, } from "../sse-event-processor.js";
10
10
  import { runOutcomeForCode } from "./run-outcome.js";
11
11
  import { acceptedPromptForMetadata, DEFAULT_TURN_SURFACE, } from "./turn-submitted.js";
12
12
  export const AGENT_NATIVE_RUN_RESUME_STATE_METADATA_KEY = "agentNativeRunResumeState";
@@ -2417,6 +2417,28 @@ function mapAgentNativeEvent(raw, input) {
2417
2417
  if (ev.type === "auto_continue") {
2418
2418
  return [{ type: "continuation", ...base }];
2419
2419
  }
2420
+ // Skipping this leaves the saved reply as a suffix of the live one, and
2421
+ // reload renders it as a second reply.
2422
+ if (ev.type === "clear") {
2423
+ clearAssistantDraftContent(input.message.content);
2424
+ if (!input.message.started)
2425
+ return [];
2426
+ const content = input.message.content
2427
+ .filter((part) => part.type === "text" || part.type === "reasoning")
2428
+ .map((part) => ({ ...part }));
2429
+ // Text the retry writes after a kept tool call is a new paragraph. The
2430
+ // replacement carries no tool calls, so an empty part marks that break.
2431
+ if (input.message.content.at(-1)?.type === "tool-call") {
2432
+ content.push({ type: "text", text: "" });
2433
+ }
2434
+ return [
2435
+ {
2436
+ type: "message-replace",
2437
+ ...base,
2438
+ message: { id: input.messageId, role: "assistant", content },
2439
+ },
2440
+ ];
2441
+ }
2420
2442
  if (ev.type === "text" || ev.type === "thinking" || ev.type === "reasoning") {
2421
2443
  const text = ev.text ?? "";
2422
2444
  const type = ev.type === "text" ? "text" : "reasoning";
@@ -354,6 +354,16 @@ export declare function processEvent(ev: SSEEvent, content: ContentPart[], toolC
354
354
  errorInfo?: AgentAutoContinueErrorInfo;
355
355
  };
356
356
  };
357
+ /**
358
+ * Drop the draft the server is about to re-emit — the narration since the last
359
+ * completed tool, not the whole turn. A `clear` arrives on a final-answer-guard
360
+ * retry or a continuation, both of which resume AFTER the last completed tool
361
+ * call, so anything before that boundary is settled multi-step narration the
362
+ * retry will never re-send. Wiping it read to users as "it deleted its reply
363
+ * and started over", and `thread-data-builder` replays this same scoping on
364
+ * rebuild, so the loss survived a reload.
365
+ */
366
+ export declare function clearAssistantDraftContent(content: ContentPart[]): void;
357
367
  /**
358
368
  * Read and process SSE events from a ReadableStream response body.
359
369
  * Yields Core-owned stream results for each meaningful event.
@@ -1891,7 +1891,7 @@ export function processEvent(ev, content, toolCallCounter, tabId, state, context
1891
1891
  * and started over", and `thread-data-builder` replays this same scoping on
1892
1892
  * rebuild, so the loss survived a reload.
1893
1893
  */
1894
- function clearAssistantDraftContent(content) {
1894
+ export function clearAssistantDraftContent(content) {
1895
1895
  for (let index = content.length - 1; index >= 0; index--) {
1896
1896
  const part = content[index];
1897
1897
  if (!part)
@@ -25,8 +25,8 @@ export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import
25
25
  ok: boolean;
26
26
  }>>;
27
27
  export declare const postCollabText: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
28
- text?: undefined;
29
28
  ok?: undefined;
29
+ text?: undefined;
30
30
  error: string;
31
31
  } | {
32
32
  error?: undefined;
@@ -13,9 +13,9 @@ export declare const postCollabPatch: import("h3").EventHandlerWithFetch<import(
13
13
  ok: boolean;
14
14
  }>>;
15
15
  export declare const getCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
+ data?: undefined;
16
17
  error: string;
17
18
  docId?: undefined;
18
- data?: undefined;
19
19
  } | {
20
20
  error?: undefined;
21
21
  docId: string;
@@ -280,7 +280,7 @@ export declare const WORKSPACE_CONNECTION_PROVIDERS: readonly [{
280
280
  readonly authorizationUrl: "https://app.hubspot.com/oauth/authorize";
281
281
  readonly tokenUrl: "https://api.hubapi.com/oauth/v3/token";
282
282
  readonly refreshUrl: "https://api.hubapi.com/oauth/v3/token";
283
- readonly scopes: readonly ["oauth", "crm.objects.contacts.read", "crm.objects.companies.read", "crm.objects.deals.read", "crm.objects.tickets.read", "crm.schemas.contacts.read", "crm.schemas.companies.read", "crm.schemas.deals.read", "crm.schemas.tickets.read"];
283
+ readonly scopes: readonly [];
284
284
  };
285
285
  readonly capabilities: readonly ["search", "import", "crm"];
286
286
  readonly recommendedTemplateUses: readonly ["analytics", "brain", "crm", "mail", "dispatch"];
@@ -245,17 +245,10 @@ export const WORKSPACE_CONNECTION_PROVIDERS = [
245
245
  authorizationUrl: "https://app.hubspot.com/oauth/authorize",
246
246
  tokenUrl: "https://api.hubapi.com/oauth/v3/token",
247
247
  refreshUrl: "https://api.hubapi.com/oauth/v3/token",
248
- scopes: [
249
- "oauth",
250
- "crm.objects.contacts.read",
251
- "crm.objects.companies.read",
252
- "crm.objects.deals.read",
253
- "crm.objects.tickets.read",
254
- "crm.schemas.contacts.read",
255
- "crm.schemas.companies.read",
256
- "crm.schemas.deals.read",
257
- "crm.schemas.tickets.read",
258
- ],
248
+ // HubSpot grants the scopes configured on the HubSpot app itself, so the
249
+ // app's config is the source of truth. Sending no scope list keeps the
250
+ // two from drifting; the granted scopes are recorded from the token.
251
+ scopes: [],
259
252
  },
260
253
  capabilities: ["search", "import", "crm"],
261
254
  recommendedTemplateUses: ["analytics", "brain", "crm", "mail", "dispatch"],
@@ -1,3 +1,4 @@
1
+ import type { McpWorkspaceConnectionAuth } from "./workspace-connection-servers.js";
1
2
  export interface McpStdioServerConfig {
2
3
  type?: "stdio";
3
4
  command: string;
@@ -21,6 +22,11 @@ export interface McpHttpServerConfig {
21
22
  firstPartyAppId?: string;
22
23
  firstPartyOrgId?: string;
23
24
  description?: string;
25
+ /**
26
+ * Authenticate each request with this owner's workspace connection instead
27
+ * of stored headers. Holds identifiers only; the token is never stored here.
28
+ */
29
+ workspaceConnection?: McpWorkspaceConnectionAuth;
24
30
  }
25
31
  export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
26
32
  export interface McpConfig {
@@ -4,6 +4,7 @@ import { plainJsonSnapshot, UnusableSchemaError, } from "../json-schema/unusable
4
4
  import { assertUsableSchema } from "../json-schema/usable-schema.js";
5
5
  import { formatMcpConnectError, httpStatusFromError } from "./errors.js";
6
6
  import { isFirstPartyRemoteEndpointTrusted, parseMergedKey, } from "./remote-store.js";
7
+ import { isWorkspaceConnectionMcpServer, resolveWorkspaceConnectionMcpBearer, } from "./workspace-connection-servers.js";
7
8
  export const MCP_TOOL_PREFIX = "mcp__";
8
9
  /**
9
10
  * Why a listed tool cannot be offered to the model, or null when it can. A
@@ -106,7 +107,9 @@ function sameServerConfig(a, b) {
106
107
  errorIdentity(b.configurationError) &&
107
108
  a.firstParty === b.firstParty &&
108
109
  a.firstPartyAppId === b.firstPartyAppId &&
109
- a.firstPartyOrgId === b.firstPartyOrgId);
110
+ a.firstPartyOrgId === b.firstPartyOrgId &&
111
+ JSON.stringify(a.workspaceConnection ?? null) ===
112
+ JSON.stringify(b.workspaceConnection ?? null));
110
113
  }
111
114
  if (a.type !== "http" && b.type !== "http") {
112
115
  return (a.command === b.command &&
@@ -334,7 +337,16 @@ export class McpClientManager {
334
337
  const requestInit = {};
335
338
  const staticHeaders = { ...(cfg.headers ?? {}) };
336
339
  const transportOptions = { requestInit };
337
- if (this.shouldInjectFirstPartyIdentity(entry)) {
340
+ if (isWorkspaceConnectionMcpServer(cfg)) {
341
+ removeAuthorizationHeaders(staticHeaders);
342
+ if (Object.keys(staticHeaders).length > 0) {
343
+ requestInit.headers = staticHeaders;
344
+ }
345
+ const auth = cfg.workspaceConnection;
346
+ const nativeFetch = fetch.bind(globalThis);
347
+ transportOptions.fetch = async (input, init) => nativeFetch(input, await buildWorkspaceConnectionRequestInit(staticHeaders, init, auth));
348
+ }
349
+ else if (this.shouldInjectFirstPartyIdentity(entry)) {
338
350
  const baselineHeaders = { ...staticHeaders };
339
351
  removeAuthorizationHeaders(baselineHeaders);
340
352
  if (Object.keys(baselineHeaders).length > 0) {
@@ -857,6 +869,17 @@ function headersToRecord(value) {
857
869
  }
858
870
  return {};
859
871
  }
872
+ async function buildWorkspaceConnectionRequestInit(staticHeaders, rawInit, auth) {
873
+ const init = rawInit && typeof rawInit === "object" ? rawInit : {};
874
+ const headers = {
875
+ ...headersToRecord(staticHeaders),
876
+ ...headersToRecord(init.headers),
877
+ };
878
+ removeAuthorizationHeaders(headers);
879
+ // Resolved per request so a refreshed token is used as soon as it exists.
880
+ headers.Authorization = `Bearer ${await resolveWorkspaceConnectionMcpBearer(auth)}`;
881
+ return { ...init, headers };
882
+ }
860
883
  async function buildFirstPartyRequestInit(staticHeaders, rawInit, trust) {
861
884
  const init = rawInit && typeof rawInit === "object" ? rawInit : {};
862
885
  const headers = {
@@ -38,6 +38,7 @@ import { mountMcpOAuthRoutes } from "./oauth-routes.js";
38
38
  import { normalizeMcpPrincipal, resolveMcpPrincipalForEvent, } from "./principal.js";
39
39
  import { InvalidMcpHeaderConfigurationError, McpHeaderSecretUnavailableError, addRemoteServer, listRemoteServers, mergedConfigKey, removeRemoteServer, toHttpServerConfigAsync, validateRemoteUrl, } from "./remote-store.js";
40
40
  import { isMcpToolAllowedForRequest } from "./visibility.js";
41
+ import { addWorkspaceConnectionMcpServers } from "./workspace-connection-servers.js";
41
42
  import { loadWorkspaceMcpServers } from "./workspace-servers.js";
42
43
  const getOrgContext = (...args) => import("../org/context.js").then(({ getOrgContext }) => getOrgContext(...args));
43
44
  export { formatMcpConnectError } from "./errors.js";
@@ -219,6 +220,7 @@ export async function buildMergedConfig(rawPrincipal) {
219
220
  for (const [mergedKey, cfg] of Object.entries(workspaceServers)) {
220
221
  servers[mergedKey] = cfg;
221
222
  }
223
+ await addWorkspaceConnectionMcpServers(servers, principal);
222
224
  // Hub-consume: if this app is configured to consume from a remote hub
223
225
  // (AGENT_NATIVE_MCP_HUB_URL + AGENT_NATIVE_MCP_HUB_TOKEN), pull its
224
226
  // org-scope servers and merge. Hub entries use `hub_<orgId>_<name>` so
@@ -0,0 +1,30 @@
1
+ import type { ProviderApiId } from "../provider-api/index.js";
2
+ import type { McpHttpServerConfig, McpServerConfig } from "./config.js";
3
+ import type { McpPrincipal } from "./principal.js";
4
+ export interface McpWorkspaceConnectionAuth {
5
+ provider: ProviderApiId;
6
+ connectionId: string;
7
+ /** The connection owner, who is also the MCP principal. */
8
+ ownerEmail: string;
9
+ orgId: string | null;
10
+ appId: string;
11
+ }
12
+ /**
13
+ * Add one MCP server per provider for which the principal has a connected
14
+ * personal workspace connection granted to this app. Organization- and
15
+ * app-scoped connections never qualify, even ones the principal created: they
16
+ * may have been authorized as another provider user, and an MCP call must act
17
+ * as the caller. A server already configured for the same endpoint wins, so
18
+ * tools are not duplicated.
19
+ */
20
+ export declare function addWorkspaceConnectionMcpServers(servers: Record<string, McpServerConfig>, principal: McpPrincipal): Promise<void>;
21
+ /**
22
+ * Resolve the bearer for one MCP request from the owner's workspace
23
+ * connection, refreshing it through the connection's own refresh path. The
24
+ * token is sent to the provider's MCP endpoint only; it is never persisted on
25
+ * the server config or shown to the agent.
26
+ */
27
+ export declare function resolveWorkspaceConnectionMcpBearer(auth: McpWorkspaceConnectionAuth): Promise<string>;
28
+ export declare function isWorkspaceConnectionMcpServer(config: McpServerConfig): config is McpHttpServerConfig & {
29
+ workspaceConnection: McpWorkspaceConnectionAuth;
30
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Remote MCP servers that authenticate with a workspace connection the user
3
+ * already has, so one provider sign-in covers both its REST API and its MCP
4
+ * server. HubSpot is the only verified provider: a token from the HubSpot app
5
+ * behind the workspace connection is accepted by mcp.hubspot.com.
6
+ */
7
+ import { getAppConfig } from "../app-config/index.js";
8
+ import { runWithRequestContext } from "../server/request-context.js";
9
+ import { normalizeMcpUrl } from "../shared/mcp-provider-hosts.js";
10
+ import { mergedConfigKey } from "./remote-store.js";
11
+ const WORKSPACE_CONNECTION_MCP_SERVERS = [["hubspot", "https://mcp.hubspot.com"]];
12
+ // Same order as the agent-chat runtime app id, so MCP sees the grants the
13
+ // app's own actions see.
14
+ function currentAppId() {
15
+ const app = getAppConfig().app;
16
+ const value = (app.id ?? app.template ?? app.workspaceId ?? app.packageName)
17
+ ?.trim()
18
+ .toLowerCase();
19
+ return value || null;
20
+ }
21
+ /**
22
+ * Add one MCP server per provider for which the principal has a connected
23
+ * personal workspace connection granted to this app. Organization- and
24
+ * app-scoped connections never qualify, even ones the principal created: they
25
+ * may have been authorized as another provider user, and an MCP call must act
26
+ * as the caller. A server already configured for the same endpoint wins, so
27
+ * tools are not duplicated.
28
+ */
29
+ export async function addWorkspaceConnectionMcpServers(servers, principal) {
30
+ const appId = currentAppId();
31
+ if (!appId)
32
+ return;
33
+ const { resolveWorkspaceConnectionsForApp } = await import("../workspace-connections/store.js");
34
+ const caller = principal.userEmail.trim().toLowerCase();
35
+ for (const [provider, url] of WORKSPACE_CONNECTION_MCP_SERVERS) {
36
+ const alreadyConfigured = Object.values(servers).some((server) => server.type === "http" &&
37
+ normalizeMcpUrl(server.url) === normalizeMcpUrl(url));
38
+ if (alreadyConfigured)
39
+ continue;
40
+ const key = mergedConfigKey("user", {
41
+ id: `workspace_connection_${provider}`,
42
+ name: provider,
43
+ url,
44
+ createdAt: 0,
45
+ }, principal.userEmail);
46
+ const description = `${provider} through your ${provider} workspace connection`;
47
+ let connectionIds;
48
+ try {
49
+ const connections = await runWithRequestContext({
50
+ userEmail: principal.userEmail,
51
+ ...(principal.orgId ? { orgId: principal.orgId } : {}),
52
+ }, () => resolveWorkspaceConnectionsForApp({ appId, provider }));
53
+ connectionIds = connections
54
+ .filter((connection) => connection.status === "connected" &&
55
+ connection.ownerEmail.trim().toLowerCase() === caller &&
56
+ connection.config.workspaceConnectionScope === "user")
57
+ .map((connection) => connection.id);
58
+ }
59
+ catch (error) {
60
+ // Keep other MCP servers working, but report this one as unreadable
61
+ // rather than as an absent connection.
62
+ servers[key] = {
63
+ type: "http",
64
+ url,
65
+ description,
66
+ configurationError: new Error(`The ${provider} workspace connection could not be read.`, { cause: error }),
67
+ };
68
+ continue;
69
+ }
70
+ // The server has no account selector, so with two personal connections
71
+ // (e.g. a sandbox and a production portal) any pick could write to the
72
+ // wrong account.
73
+ if (connectionIds.length > 1) {
74
+ servers[key] = {
75
+ type: "http",
76
+ url,
77
+ description,
78
+ configurationError: new Error(`You have ${connectionIds.length} personal ${provider} connections for this app, so the ${provider} MCP server can't tell which account to use. Disconnect the extra ones, or add the ${provider} MCP server yourself.`),
79
+ };
80
+ continue;
81
+ }
82
+ const connectionId = connectionIds[0];
83
+ if (!connectionId)
84
+ continue;
85
+ servers[key] = {
86
+ type: "http",
87
+ url,
88
+ description,
89
+ workspaceConnection: {
90
+ provider,
91
+ connectionId,
92
+ ownerEmail: principal.userEmail,
93
+ orgId: principal.orgId,
94
+ appId,
95
+ },
96
+ };
97
+ }
98
+ }
99
+ /**
100
+ * Resolve the bearer for one MCP request from the owner's workspace
101
+ * connection, refreshing it through the connection's own refresh path. The
102
+ * token is sent to the provider's MCP endpoint only; it is never persisted on
103
+ * the server config or shown to the agent.
104
+ */
105
+ export async function resolveWorkspaceConnectionMcpBearer(auth) {
106
+ const { createProviderApiRuntime } = await import("../provider-api/index.js");
107
+ const runtime = createProviderApiRuntime({ appId: auth.appId });
108
+ const token = await runWithRequestContext({
109
+ userEmail: auth.ownerEmail,
110
+ ...(auth.orgId ? { orgId: auth.orgId } : {}),
111
+ }, () => runtime.resolveOAuthAccessToken({
112
+ provider: auth.provider,
113
+ connectionId: auth.connectionId,
114
+ }));
115
+ return token.accessToken;
116
+ }
117
+ export function isWorkspaceConnectionMcpServer(config) {
118
+ return config.type === "http" && !!config.workspaceConnection;
119
+ }
@@ -1,18 +1,18 @@
1
1
  export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
2
2
  error?: undefined;
3
- ok?: undefined;
4
3
  count: number;
5
4
  updated?: undefined;
5
+ ok?: undefined;
6
6
  } | {
7
7
  error?: undefined;
8
- ok?: undefined;
9
8
  count?: undefined;
10
9
  updated: number;
11
- } | {
12
10
  ok?: undefined;
11
+ } | {
13
12
  count?: undefined;
14
13
  updated?: undefined;
15
14
  error: string;
15
+ ok?: undefined;
16
16
  } | {
17
17
  error?: undefined;
18
18
  count?: undefined;