All files / src/entities/context accounts.ts

100% Statements 39/39
100% Branches 20/20
100% Functions 7/7
100% Lines 39/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131          22x           22x 22x 22x 22x 22x 22x                                       13x     22x 1x     22x 5x 5x           10x 5x 5x 5x 1x   4x     1x     5x                       22x             22x 6x 6x 6x 6x 6x       6x 6x 6x 4x 4x                 3x   1x     6x                         22x 1x 1x            
/**
 * manage_context actions for the GitLab accounts of a connection: list them, switch the
 * chat to one, link another through the browser, unlink one.
 */
 
import {
  ChatAccount,
  listChatAccounts,
  switchChatAccount,
  unlinkChatAccount,
} from '../../accounts/chat-accounts';
import { startAccountLink } from '../../accounts/link';
import { currentCaller } from '../../configuration/caller';
import { HANDLER_TIMEOUT_MS } from '../../config';
import { currentMcpServer } from '../../mcp-server-context';
import { generateRandomString } from '../../oauth/token-utils';
import { logDebug } from '../../logger';
import type { LinkAccountInput, SwitchAccountInput, UnlinkAccountInput } from './schema';
 
export interface AccountChangeResult {
  success: boolean;
  account: ChatAccount;
  message: string;
}
 
export interface AccountLinkResult {
  url: string;
  instanceUrl: string;
  instanceLabel?: string;
  expiresAt: string;
  /** Whether the client opened the link for the user (URL elicitation). */
  opened: boolean;
  message: string;
}
 
function chatSessionId(): string | undefined {
  return currentCaller()?.sessionKey;
}
 
export async function handleListAccounts(): Promise<ChatAccount[]> {
  return listChatAccounts(chatSessionId());
}
 
export async function handleSwitchAccount(input: SwitchAccountInput): Promise<AccountChangeResult> {
  const sessionId = chatSessionId();
  const account = await switchChatAccount(sessionId, input.account);
  // The chat's tools follow the account's instance, version and permissions. A session moved
  // to another instance is told by the session manager; otherwise (same instance, other
  // permissions, or the sessionless stdio chat) it is told here. An instance not initialized
  // yet lists unfiltered tools now; the first call that initializes it announces the list
  // again (the tool-call bootstrap), so the client then lists the filtered tools.
  const { getSessionManager } = await import('../../session-manager');
  const sessions = getSessionManager();
  const previousUrl = sessionId ? sessions.getSessionInstanceUrl(sessionId) : undefined;
  if (previousUrl !== undefined && previousUrl !== account.instanceUrl) {
    sessions.setSessionInstanceUrl(sessionId as string, account.instanceUrl);
  } else {
    await currentMcpServer()
      ?.sendToolListChanged()
      .catch((error: unknown) =>
        logDebug('Could not announce the tool list change', { err: error }),
      );
  }
  return {
    success: true,
    account,
    message: `This chat now works with ${account.label}; other chats keep their account.`,
  };
}
 
/**
 * How long the client may take to answer the URL elicitation. Its answer is the user's
 * consent to open the link, not the end of the sign-in; a client that holds it longer must
 * not hold the tool call past its timeout, and the link is returned either way.
 */
const LINK_ELICITATION_WAIT_MS = Math.min(30_000, HANDLER_TIMEOUT_MS / 2);
 
/**
 * Ask the client to open the sign-in link (MCP URL elicitation) when it can; the link is
 * returned either way for the user to open. The chat may be told when the sign-in completes
 * (best effort, see `notifyElicitationComplete`).
 */
export async function handleLinkAccount(input: LinkAccountInput): Promise<AccountLinkResult> {
  const server = currentMcpServer();
  const opensLinks = server?.getClientCapabilities()?.elicitation?.url !== undefined;
  const elicitationId = opensLinks ? generateRandomString(16) : undefined;
  const mcpSessionId = chatSessionId();
  const link = await startAccountLink(
    input.instance,
    elicitationId && mcpSessionId ? { mcpSessionId, elicitationId } : undefined,
  );
  const instance = link.instanceLabel ?? link.instanceUrl;
  let opened = false;
  if (server && elicitationId) {
    try {
      const answer = await server.elicitInput(
        {
          mode: 'url',
          message: `Sign in to ${instance} with the GitLab account to link to this connection.`,
          url: link.url,
          elicitationId,
        },
        { timeout: LINK_ELICITATION_WAIT_MS },
      );
      opened = answer.action === 'accept';
    } catch (error: unknown) {
      logDebug('URL elicitation for account linking failed', { err: error });
    }
  }
  return {
    url: link.url,
    instanceUrl: link.instanceUrl,
    instanceLabel: link.instanceLabel,
    expiresAt: new Date(link.expiresAt).toISOString(),
    opened,
    message:
      (opened ? 'The sign-in page is open. ' : 'Open the link in the browser to sign in. ') +
      `GitLab first confirms your own account, then you sign in with the account to link on ${instance}. ` +
      'The link works once and for 10 minutes; afterwards list_accounts shows the new account.',
  };
}
 
export async function handleUnlinkAccount(input: UnlinkAccountInput): Promise<AccountChangeResult> {
  const account = await unlinkChatAccount(chatSessionId(), input.account);
  return {
    success: true,
    account,
    message: `${account.label} is no longer linked; this server's access to it (its OAuth grant) was revoked.`,
  };
}