Blog

Google Workspace MCP を Managed Agents で動かす

2026-08-16 18:56:58

Google Workspace MCP を Managed Agents で動かす

Claude Desktop や claude.ai でGmailやカレンダーを使うだけなら、カスタムコネクタとして繋ぐだけで済みます。画面の指示どおりGoogleにログインして許可すれば、あとは会話の中で予定を読んだり、下書きを作ったりできます。手元で使う分には、これで十分です。

条件が変わるのは、これをシステムに組み込むときです。利用者それぞれの代理でWorkspaceを触る。誰も画面を見ていない時間にも動かす。そうなると認証情報やエージェントの振る舞いを管理する必要があります。

この用途に使えるのが、Google Workspace MCP と Managed Agents の Vault の組み合わせです。Googleが公式MCPサーバを出しているので、APIを叩くコードは書かずに済み、トークンをVaultに預けておけば、保管/更新の機能をClaude側へ移譲できます。

この記事では、Google認証とどう繋ぐのか。使わせるツールをどのように設定するか。自動実行/承認待ちをどう切り替えるのか。実検証を交えて解説します。

Google Workspace MCP とは

MCPは何を決めている規格か

MCP(Model Context Protocol)は、AIアプリケーションと外部サービスの間の通信を規定したプロトコルです。JSON-RPC 2.0 の上に、やり取りする役割・メソッド・型を定めています。(参考「Model Context Protocol (MCP)」

登場するのは3つです。

  • MCPサーバ: APIやデータベースの機能を公開
  • MCPホスト: 利用者から見たAIアプリケーション本体
  • MCPクライアント: ホストの中で、サーバとの通信を担当する部分

今回の構成では、Google Cloud がサーバ、Managed Agents がホスト、クライアントを実装しているのはAnthropicです。

サーバが公開できるものも3種類に決まっています。

  • tools: AIが実行できる操作
  • prompts: サーバ側が用意した定型の指示文
  • resources: AIに読ませるデータ

認可は OAuth 2.1 を基礎に据えていて、MCPサーバはOAuthの保護対象リソースとして扱われます。

バージョンは YYYY-MM-DD の形式で、後方非互換な変更が最後に入った日を表します。これまでに5回改訂されました。

2024-11-052025-03-262025-06-182025-11-252026-07-28

どの版に対応しているかはサーバごとに異なります。2026年8月時点において、Google Cloud のMCPはバージョン「2025-11-25」に対応しています。(参考: 「Google Cloud MCP servers overview」)。

一方、最新は「2026-07-28」です。1つ違うだけですが、この2つの間には「世代(レガシー / モダン)」の境目があります。

主な差分は下記です。

項目

2025-11-25(レガシー)

2026-07-28(モダン)

接続の開始

initialize でハンドシェイクし、Mcp-Session-Id でセッションを張る

ハンドシェイクを廃止。毎リクエストの _meta にバージョンと能力を載せる

能力の確認

initialize の応答と tools/list など

server/discover を追加(サーバは実装必須)

サーバからの問い合わせ

サーバ起点のリクエスト(elicitation/create など)

MRTR。サーバは resultType: "input_required" を返し、クライアントが答えを付けて再送する

結果の形

規定なし

resultType が必須

一覧のキャッシュ

規定なし

ttlMscacheScope が必須

HTTPヘッダ

規定なし

Mcp-MethodMcp-Name が必須(本文を読まずに振り分けられる)

「モダン」では、セッションに状態を持つステートフルな設計から、1リクエストで完結するステートレスな設計へ変わりました。また、モダンクライアントからレガシーサーバに繋ぐと失敗するため、サーバーに合わせてクライアントはプロトコルを切り替える責任があります。

つまり、Managed Agents 経由でMCP接続する場合、その実装者であるAnthropicがバージョン差分を吸収してくれます。利用する側が書くのは接続先のURLと資格情報を渡すコードだけです。

プロダクトごとのサーバURLとツール

Google Workspace MCP は、プロダクトごとに専用サーバがあり、AIから「読む」「操作する」ができ、ログインしたユーザーと同じ権限・ガバナンスを継承します。(参考: 「Configure the Google Workspace MCP servers」

プロダクト

サーバURL

代表的なツール

Gmail

https://gmailmcp.googleapis.com/mcp/v1

search_threads / get_thread / create_draft / list_labels

Google Drive

https://drivemcp.googleapis.com/mcp/v1

search_files / read_file_content / create_file

Google Calendar

https://calendarmcp.googleapis.com/mcp/v1

list_events / create_event / update_event / delete_event / suggest_time

Google Chat

https://chatmcp.googleapis.com/mcp/v1

search_messages / send_message

People

https://people.googleapis.com/mcp/v1

get_user_profile / search_contacts

Managed Agents からのMCP利用

Vault 機能

Managed Agents において、認証情報を受け持つのが Vault です。エンドユーザーごとの資格情報を登録しておき、セッションを作るときにそのIDを渡すと、MCPサーバへの接続時にトークンが注入されます。有効期限が切れたときの更新も、リフレッシュの情報を一緒に預けておけば代行されます。自前のシークレットストアを運用する必要も、リクエストのたびにトークンを載せる必要もなくなります。(参考: 「Authenticate with vaults」

全部を自分で作った場合と並べて、何をマネージドサービスに移譲できるかを整理します。

やること

すべて自前で実装する場合

Managed Agents + Vault

OAuthのクライアント登録

自分で用意

自分で用意

認可フロー(同意画面・state・PKCE・callback)

自分で実装

自分で実装

認可コードとトークンの交換

自分で実装

自分で実装

アクセストークンの保管

自前のシークレットストア

Vaultに登録して、IDで参照

アクセストークンの更新

自分で refresh を実装

refresh を登録しておけば代行される

各APIの呼び出し(Gmail/Calendar…)

エンドポイント・引数を自分で実装

サーバが公開するツールを呼ぶだけ

Authorization: Bearer の差し込み

実行層で自分で付与

サーバ接続時に自動で注入

ユーザーごとの切り替え

実装ごとにトークンを持ち回る

セッション作成時に vault_id を変えるだけ

権限・監査

自作

ユーザーの権限を継承・mcp_tool_use イベントで観測

どう繋ぎ、どう安全に使うか - 全体の流れを1枚で見る

登場人物が多いので、先に通しで並べます。認可を担う部分は独立したサービスに切り出しています(アプリ本体に混ぜてもよいのですが、Cookieとリダイレクトを扱う責務が他と混ざりやすいので分けました)。

シーケンス図のNo.1-25が認可、No.26-35がMCP利用です。

認可ステップは利用者ごとに1回だけ通ります。ここまでは、MCPがあってもなくても変わらない普通のOAuthです。

MCP利用ステップにトークンは出てきません。渡すのは vault_id だけで、Googleに対する認証はAnthropic側が資格情報を引いて行います。自前で作るなら、トークンの取り出しと期限確認が毎回入ります。消えたのはそこです。

平文のトークンが自社サーバを通るのは、自社認可サーバから受け取ってVaultへ登録するまでの一瞬だけになります。永続化するのは user_idvault_id の対応表です。

Vaultは利用者ごとの入れ物で、その中に接続先URLごとの資格情報が入ります。今回はCalendarとGmailに繋ぐので、1つのVaultに資格情報が2件です。セッションに渡すのは vault_id のほうで、資格情報のIDを個別に指定することはありません。どの資格情報を使うかは、エージェント定義に書いたサーバのURLと mcp_server_url の一致で決まります。

要点: 自前で書くのは「トークンを手に入れるまで」。Vaultに預けた後は vault_id だけで回る。

Google Auth Platform の準備

さらに前段として、下記のGoogle Cloud 側の準備が必要です。

  1. APIを有効化: Gmail・Drive・Calendar・Chat・People の各API。
  2. MCPサービスを有効化: gmailmcp / drivemcp / calendarmcp / chatmcp / people の各MCPサービス。
  3. OAuth同意画面を設定: アプリ名・対象(社内利用なら Internal 推奨)・使うスコープを登録。
  4. OAuthクライアントを作成: Webアプリとして作り、クライアントIDとシークレットを控える。リダイレクトURIには自社認可サーバのcallbackを登録する。

Claude.ai や Claude Desktop からカスタムコネクタとして使うなら、リダイレクトURIに https://claude.ai/api/mcp/auth_callback を登録すれば、認可はClaude側が引き受けてくれます。 繋ぐだけで済むのはこの経路だからです。一方、自社アプリからManaged Agents経由で動かす場合はこの経路を通らないので、callbackは自分で受けます。

なお、リフレッシュトークンを確実に受け取るには、認可リクエストに access_type=offline が要ります。同意済みの利用者に再度リクエストしても返ってこないことがあるので、初回の応答で保存し損ねると prompt=consent で取り直すことになります。

使うツールだけを許可し、実行前に承認を挟む

MCPサーバは他人が管理するものなので、意図しないツールがいつ増えるかわかりません。

そのため、default_configenabled: false として、全てのツールを無効にし、使うものだけを明示的に有効にする利用が推奨されています。

もう一段の守りとして、permission policy があります。デフォルトの挙動は always_ask で、ツールを呼ぶ前にセッションが止まり、承認を待ちます。止まったセッションは requires_action の状態でイベントを出し、こちらが user.tool_confirmationallowdeny を返すまで待ち続けます。拒否した場合は、その理由がツール結果としてエージェントに渡るので、エージェントは別の手を試せます。書き込みや削除を許すときは、この承認を挟むのが基本です。

間接プロンプトインジェクションに注意

MCPクライアントは強力なツールを通じてあなたのアカウントのデータに参照・変更・削除ができます。そこに未検証の入力(差出人不明のメール、外部から来た文書)を処理させると、その中に埋め込まれた隠し指示にセッションを乗っ取られ、データの窃取・改ざん・削除につながる恐れがあります。守る手段は3つです。

  • 信頼できるツールにだけ繋ぐ(未検証のMCPサーバに繋がない)
  • 未検証の入力を安易に処理させない(外部メール・文書の要約などは特に注意)
  • AIの操作は必ず人が確認する(不可逆な操作の前に承認ステップを置く)

これはコンテキストエンジニアリングの権限における設計ともいえます。MCPで手間は減りますが、権限を渡す相手と、流し込む入力への警戒はむしろ過敏になりましょう。

実検証: 「予定を確認して -> メールドラフトを作成する」

本検証では下記のプロンプトを実践します。

「今日から3日以内に予定されている打ち合わせをすべて確認し、1件につき1通、参加者宛に『体調不良のためリモート会議に切り替えてほしい』というメールの下書きを作ってください。該当が無ければ下書きは作らず、その旨だけ答えてください。送信はしないでください」

条件を入れているのは、エージェントに判断させたいからです。期間で絞り、何件あるかを数え、それぞれについて宛先と本文を変えて書き、該当が無ければ作らない、と明示しています。

読む先はGoogle Calendar MCP、書く先はGmail MCP。サーバが2つに分かれるので、権限の設計もサーバごとに決めます。

  • カレンダー: 読み取り系(list_events / get_event)だけを有効にする
  • Gmail: create_draft だけを有効にする。送信系(send_message など)は有効にしないので、そもそも呼べない

そのうえで、どちらも always_allow にして最後まで止めずに通します。MCPツールセットの既定は always_ask なので、放っておくと下書き作成の直前で止まります。

access_tokenrefresh_token は、自前の認可フローで取得済みの前提でsetupします。

import static com.google.common.base.Preconditions.checkState;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.JsonValue;
import com.anthropic.core.http.StreamResponse;
import com.anthropic.models.beta.agents.AgentCreateParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsAlwaysAllowPolicy;
import com.anthropic.models.beta.agents.BetaManagedAgentsMcpToolConfigParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsMcpToolsetDefaultConfigParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsMcpToolsetParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsModel;
import com.anthropic.models.beta.agents.BetaManagedAgentsModelConfigParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsUrlMcpServerParams;
import com.anthropic.models.beta.environments.EnvironmentCreateParams;
import com.anthropic.models.beta.sessions.SessionCreateParams;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsStreamSessionEvents;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsTextBlock;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsUserMessageEventParams;
import com.anthropic.models.beta.sessions.events.EventSendParams;
import com.anthropic.models.beta.vaults.VaultCreateParams;
import com.anthropic.models.beta.vaults.credentials.BetaManagedAgentsMcpOAuthCreateParams;
import com.anthropic.models.beta.vaults.credentials.BetaManagedAgentsMcpOAuthRefreshParams;
import com.anthropic.models.beta.vaults.credentials.CredentialCreateParams;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import java.io.BufferedWriter;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Iterator;
import java.util.List;
import java.util.Map;


/**
 * Google Workspace MCP に Managed Agents から接続するデモ。
 *
 * <p>Calendar は読み取りだけ、Gmail は下書き作成だけを許可する。 OAuth トークンは Vault に保存し、実行時は Vault ID
 * だけを Session に渡す。
 * 
 * <p>セットアップ: 自前の認可フローで取得済みのトークンを環境変数に入れて、setupを行う
 * 実行前に設定: ANTHROPIC_API_KEY / GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REFRESH_TOKEN
 * mvn -q compile exec:java -Dexec.args="setup"
 *
 * <p>実行: ANTHROPIC_ENVIRONMENT_ID / ANTHROPIC_AGENT_ID / ANTHROPIC_VAULT_ID を設定して
 * mvn -q compile exec:java
*/
public final class WorkspaceMcpAgent {

  private static final String CALENDAR_MCP_URL = "https://calendarmcp.googleapis.com/mcp/v1";
  private static final String GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1";
  private static final String GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";

  private static final String CALENDAR_SCOPE =
      "https://www.googleapis.com/auth/calendar.events.readonly";
  private static final String GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.compose";

  private static final Path DEFAULT_OUTPUT_PATH = Path.of("output.md");
  private static BufferedWriter outputWriter;

  private static final String TASK =
      """
      今日から3日以内に予定されている打ち合わせをすべて確認してください。

      該当した打ち合わせ1件につき1通、その参加者宛に次の内容のメールの下書きを作成してください。
      複数該当する場合は、すべてについて作成してください。

      - 体調不良のため、対面ではなくリモート会議に切り替えてほしいこと
      - どの打ち合わせのことか分かるよう、会議名と日時に触れること
      - 内容は簡潔に、事務的になりすぎない範囲で丁寧に

      3日以内に該当する打ち合わせが無ければ、下書きは作らず、その旨だけを答えてください。
      予定の内容を推測で補わないでください。下書きの作成までで構いません。送信はしないでください。
      """;

  /** SDK が返した MCP input/result と Google OAuth レスポンスを扱う。 */
  private static final ObjectMapper JSON =
      new ObjectMapper().findAndRegisterModules().enable(SerializationFeature.INDENT_OUTPUT);

  private static final BetaManagedAgentsAlwaysAllowPolicy ALWAYS_ALLOW =
      BetaManagedAgentsAlwaysAllowPolicy.builder()
          .type(BetaManagedAgentsAlwaysAllowPolicy.Type.ALWAYS_ALLOW)
          .build();

  private final AnthropicClient client;
  private final HttpClient httpClient = HttpClient.newHttpClient();

  /** agent.mcp_tool_use の event id -> 呼び出し情報。tool_result と対応付ける。 */
  private final Map<String, PendingCall> pendingCalls = Maps.newHashMap();

  private record PendingCall(String server, String tool, String inputJson) {}

  private record RunResult(int successfulDrafts, String answer) {}

  private record GoogleAccessToken(String accessToken, OffsetDateTime expiresAt) {}

  WorkspaceMcpAgent(AnthropicClient client) {
    this.client = client;
  }

  public static void main(String[] args) throws IOException {
    Path outputPath = resolveOutputPath();
    createParentDirectories(outputPath);

    try (BufferedWriter writer = Files.newBufferedWriter(outputPath, UTF_8)) {
      outputWriter = writer;

      try {
        WorkspaceMcpAgent app = new WorkspaceMcpAgent(AnthropicOkHttpClient.fromEnv());

        if (args.length > 0 && "setup".equalsIgnoreCase(args[0])) {
          app.setup();
          return;
        }

        app.run(
            requiredEnvironment("ANTHROPIC_ENVIRONMENT_ID"),
            requiredEnvironment("ANTHROPIC_AGENT_ID"),
            requiredEnvironment("ANTHROPIC_VAULT_ID"));
      } catch (RuntimeException exception) {
        out();
        out("## エラー");
        out();
        out("- 種類: `" + exception.getClass().getName() + "`");
        out("- メッセージ: " + String.valueOf(exception.getMessage()));
        throw exception;
      }
    } finally {
      outputWriter = null;
    }
  }

  /** Environment / Agent / Vault を作成し、Google の OAuth credential を Vault に登録する。 */
  void setup() {
    String clientId = requiredEnvironment("GOOGLE_CLIENT_ID");
    String clientSecret = requiredEnvironment("GOOGLE_CLIENT_SECRET");

    var environment =
        client
            .beta()
            .environments()
            .create(EnvironmentCreateParams.builder().name("workspace-mcp-environment").build());

    var agent =
        client
            .beta()
            .agents()
            .create(
                AgentCreateParams.builder()
                    .name("meeting-assistant")
                    .model(
                        BetaManagedAgentsModelConfigParams.builder()
                            .id(BetaManagedAgentsModel.CLAUDE_SONNET_5)
                            .build())
                    .system(
                        "カレンダーとメールを扱うアシスタントです。取得した情報だけを根拠に行動し、"
                            + "推測で予定や宛先を補わないでください。下書きの作成までで止め、送信はしません。")
                    .addMcpServer(mcpServer("calendar", CALENDAR_MCP_URL))
                    .addMcpServer(mcpServer("gmail", GMAIL_MCP_URL))
                    .addTool(mcpToolset("calendar", ImmutableList.of("list_events", "get_event")))
                    .addTool(mcpToolset("gmail", ImmutableList.of("create_draft")))
                    .build());

    var vault =
        client
            .beta()
            .vaults()
            .create(
                VaultCreateParams.builder()
                    .displayName("Masahiro")
                    .metadata(
                        VaultCreateParams.Metadata.builder()
                            .putAdditionalProperty(
                                "external_user_id", JsonValue.from("usr_masahiro"))
                            .build())
                    .build());

    registerCredential(
        vault.id(),
        "Google Calendar",
        CALENDAR_MCP_URL,
        CALENDAR_SCOPE,
        clientId,
        clientSecret,
        requiredEnvironment("GOOGLE_REFRESH_TOKEN"));

    registerCredential(
        vault.id(),
        "Gmail",
        GMAIL_MCP_URL,
        GMAIL_SCOPE,
        clientId,
        clientSecret,
        requiredEnvironment("GOOGLE_REFRESH_TOKEN"));

    out("# setup completed");
    out();
    out("export ANTHROPIC_ENVIRONMENT_ID=\"" + environment.id() + "\"");
    out("export ANTHROPIC_AGENT_ID=\"" + agent.id() + "\"");
    out("export ANTHROPIC_VAULT_ID=\"" + vault.id() + "\"");
  }

  /** MCP server 定義。 */
  private static BetaManagedAgentsUrlMcpServerParams mcpServer(String name, String url) {
    return BetaManagedAgentsUrlMcpServerParams.builder()
        .type(BetaManagedAgentsUrlMcpServerParams.Type.URL)
        .name(name)
        .url(url)
        .build();
  }

  /**
   * MCP toolset の許可リストを作る。
   *
   * <p>default は enabled=false にして、明示した tool だけ enabled=true にする。 permission は always_allow
   * に固定するため、実行途中で確認待ちにならない。
   */
  private static BetaManagedAgentsMcpToolsetParams mcpToolset(
      String serverName, List<String> enabledTools) {
    var builder =
        BetaManagedAgentsMcpToolsetParams.builder()
            .type(BetaManagedAgentsMcpToolsetParams.Type.MCP_TOOLSET)
            .mcpServerName(serverName)
            .defaultConfig(
                BetaManagedAgentsMcpToolsetDefaultConfigParams.builder()
                    .enabled(false)
                    .permissionPolicy(ALWAYS_ALLOW)
                    .build());

    enabledTools.forEach(
        toolName ->
            builder.addConfig(
                BetaManagedAgentsMcpToolConfigParams.builder()
                    .name(toolName)
                    .enabled(true)
                    .build()));

    return builder.build();
  }

  /** refresh token から最新 access token を取得し、expires_in から expiresAt を自動計算して Vault に登録する。 */
  private void registerCredential(
      String vaultId,
      String displayName,
      String serverUrl,
      String scope,
      String clientId,
      String clientSecret,
      String refreshToken) {

    GoogleAccessToken token = refreshGoogleAccessToken(clientId, clientSecret, refreshToken);

    var refresh =
        BetaManagedAgentsMcpOAuthRefreshParams.builder()
            .tokenEndpoint(GOOGLE_TOKEN_ENDPOINT)
            .clientId(clientId)
            .clientSecretPostTokenEndpointAuth(clientSecret)
            .scope(scope)
            .refreshToken(refreshToken)
            .build();

    var oauth =
        BetaManagedAgentsMcpOAuthCreateParams.builder()
            .type(BetaManagedAgentsMcpOAuthCreateParams.Type.MCP_OAUTH)
            .mcpServerUrl(serverUrl)
            .accessToken(token.accessToken())
            .expiresAt(token.expiresAt())
            .refresh(refresh)
            .build();

    client
        .beta()
        .vaults()
        .credentials()
        .create(
            CredentialCreateParams.builder()
                .vaultId(vaultId)
                .displayName(displayName)
                .auth(oauth)
                .build());

    out("- " + displayName + " credential registered (expiresAt: " + token.expiresAt() + ")");
  }

  /** Google OAuth token endpoint に refresh_token grant を送り、新しい access token を取得する。 */
  private GoogleAccessToken refreshGoogleAccessToken(
      String clientId, String clientSecret, String refreshToken) {

    String form =
        "client_id="
            + formEncode(clientId)
            + "&client_secret="
            + formEncode(clientSecret)
            + "&refresh_token="
            + formEncode(refreshToken)
            + "&grant_type=refresh_token";

    HttpRequest request =
        HttpRequest.newBuilder(URI.create(GOOGLE_TOKEN_ENDPOINT))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(form, UTF_8))
            .build();

    try {
      HttpResponse<String> response =
          httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8));

      if (response.statusCode() < 200 || response.statusCode() >= 300) {
        throw new IllegalStateException(
            "Google OAuth token refresh failed: HTTP "
                + response.statusCode()
                + " / "
                + sanitizeGoogleError(response.body()));
      }

      JsonNode body = JSON.readTree(response.body());
      String accessToken = body.path("access_token").asText("");
      long expiresIn = body.path("expires_in").asLong(-1);

      checkState(!accessToken.isBlank(), "Google OAuth response に access_token がありません");
      checkState(expiresIn > 0, "Google OAuth response の expires_in が不正です: %s", expiresIn);

      OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(expiresIn);
      return new GoogleAccessToken(accessToken, expiresAt);

    } catch (InterruptedException exception) {
      Thread.currentThread().interrupt();
      throw new IllegalStateException("Google OAuth token refresh が中断されました", exception);
    } catch (IOException exception) {
      throw new IllegalStateException("Google OAuth token refresh に失敗しました", exception);
    }
  }

  private static String formEncode(String value) {
    return URLEncoder.encode(value, UTF_8);
  }

  /** エラー本文に token 等が含まれないよう、Google が返した代表的なエラー項目だけを取り出す。 */
  private static String sanitizeGoogleError(String responseBody) {
    try {
      JsonNode body = JSON.readTree(responseBody);
      String error = body.path("error").asText("unknown_error");
      String description = body.path("error_description").asText("");
      return description.isBlank() ? error : error + ": " + description;
    } catch (JsonProcessingException exception) {
      return "response body could not be parsed";
    }
  }

  /** Session を作成して TASK を送り、完了までストリームを読む。 */
  void run(String environmentId, String agentId, String vaultId) {
    pendingCalls.clear();

    var session =
        client
            .beta()
            .sessions()
            .create(
                SessionCreateParams.builder()
                    .environmentId(environmentId)
                    .agent(agentId)
                    .addVaultId(vaultId)
                    .title("打ち合わせのリモート切り替え依頼")
                    .build());

    sendTask(session.id());
    RunResult result = consumeEvents(session.id());

    out();
    out("作成に成功した下書き: " + result.successfulDrafts() + " 件");
    out("A. " + result.answer());
  }

  private void sendTask(String sessionId) {
    client
        .beta()
        .sessions()
        .events()
        .send(
            EventSendParams.builder()
                .sessionId(sessionId)
                .addUserMessageEvent(
                    ImmutableList.of(
                        BetaManagedAgentsUserMessageEventParams.Content.ofText(
                            BetaManagedAgentsTextBlock.builder()
                                .type(BetaManagedAgentsTextBlock.Type.TEXT)
                                .text(TASK)
                                .build())))
                .build());
  }

  private RunResult consumeEvents(String sessionId) {
    StringBuilder answer = new StringBuilder();
    int successfulDrafts = 0;

    try (StreamResponse<BetaManagedAgentsStreamSessionEvents> response =
        client.beta().sessions().events().streamStreaming(sessionId)) {

      Iterator<BetaManagedAgentsStreamSessionEvents> events = response.stream().iterator();

      while (events.hasNext()) {
        BetaManagedAgentsStreamSessionEvents event = events.next();

        if (event.isAgentMcpToolUse()) {
          handleMcpToolUse(event);
          continue;
        }

        if (event.isAgentMcpToolResult()) {
          if (isSuccessfulDraftResult(event)) {
            successfulDrafts++;
          }
          continue;
        }

        if (event.isAgentMessage()) {
          event.asAgentMessage().content().forEach(block -> answer.append(block.text()));
          continue;
        }

        if (event.isSessionError()) {
          throw new IllegalStateException("session error: " + event.asSessionError());
        }

        if (event.isSessionStatusIdle()) {
          var stopReason = event.asSessionStatusIdle().stopReason();

          if (stopReason.isEndTurn()) {
            break;
          }

          if (stopReason.isRequiresAction()) {
            String calls =
                Joiner.on(", ")
                    .join(
                        stopReason.asRequiresAction().eventIds().stream()
                            .map(id -> String.valueOf(pendingCalls.get(id)))
                            .toList());
            throw new IllegalStateException("承認待ちで停止しました。permission policy を確認してください: " + calls);
          }
        }
      }
    }

    return new RunResult(successfulDrafts, answer.toString().strip());
  }

  private void handleMcpToolUse(BetaManagedAgentsStreamSessionEvents event) {
    var toolUse = event.asAgentMcpToolUse();
    String inputJson = toPrettyJson(toolUse.input());

    pendingCalls.put(
        toolUse.id(), new PendingCall(toolUse.mcpServerName(), toolUse.name(), inputJson));

    out("[mcp_tool_use] " + toolUse.mcpServerName() + " / " + toolUse.name());
    out(inputJson);
  }

  /** create_draft の MCP 実行結果が成功した場合だけ true。 */
  private boolean isSuccessfulDraftResult(BetaManagedAgentsStreamSessionEvents event) {
    var result = event.asAgentMcpToolResult();
    PendingCall call = pendingCalls.remove(result.mcpToolUseId());
    boolean failed = result.isError().orElse(false);

    out(
        "[mcp_tool_result] "
            + (call == null ? "unknown" : call.server() + " / " + call.tool())
            + " / success="
            + !failed);
    result.content().ifPresent(content -> out(toPrettyJson(content)));

    return !failed
        && call != null
        && "gmail".equals(call.server())
        && "create_draft".equals(call.tool());
  }

  private static String toPrettyJson(Object value) {
    try {
      return JSON.writeValueAsString(value);
    } catch (JsonProcessingException | IllegalArgumentException exception) {
      return String.valueOf(value);
    }
  }

  private static void out() {
    out("");
  }

  private static void out(String text) {
    System.out.println(text);
    writeText(text + System.lineSeparator());
  }

  private static void writeText(String text) {
    if (outputWriter == null) {
      return;
    }

    try {
      outputWriter.write(text);
      outputWriter.flush();
    } catch (IOException exception) {
      throw new IllegalStateException(resolveOutputPath() + " への書き込みに失敗しました", exception);
    }
  }

  private static Path resolveOutputPath() {
    String configured = System.getenv("OUTPUT_FILE");
    return Strings.isNullOrEmpty(configured) ? DEFAULT_OUTPUT_PATH : Path.of(configured);
  }

  private static void createParentDirectories(Path outputPath) throws IOException {
    Path parent = outputPath.toAbsolutePath().getParent();
    if (parent != null) {
      Files.createDirectories(parent);
    }
  }

  private static String requiredEnvironment(String name) {
    String value = System.getenv(name);
    checkState(!Strings.isNullOrEmpty(value) && !value.isBlank(), "%s を設定してください", name);
    return value;
  }
}

実行するとこうなります。

[mcp_tool_use] calendar / list_events
{
  "orderBy": "startTime",
  "pageSize": 10
}

[mcp_tool_result] calendar / list_events / success=true
{
  "events": [
    {
      "start": {
        "dateTime": "2026-08-17T14:35:00+09:00",
        "timeZone": "Asia/Tokyo"
      },
      "end": {
        "dateTime": "2026-08-17T15:00:00+09:00",
        "timeZone": "Asia/Tokyo"
      },
      "summary": "社内定例A",
      "attendees": [
        {
          "email": "member-a@example.com",
          "responseStatus": "needsAction"
        }
      ]
    },
    {
      "start": {
        "dateTime": "2026-08-18T09:00:00+09:00",
        "timeZone": "Asia/Tokyo"
      },
      "end": {
        "dateTime": "2026-08-18T10:00:00+09:00",
        "timeZone": "Asia/Tokyo"
      },
      "summary": "社外打ち合わせA",
      "status": "confirmed"
    },
    {
      "start": {
        "dateTime": "2026-08-19T09:05:00+09:00",
        "timeZone": "Asia/Tokyo"
      },
      "end": {
        "dateTime": "2026-08-19T10:00:00+09:00",
        "timeZone": "Asia/Tokyo"
      },
      "summary": "開発定例A",
      "attendees": [
        {
          "email": "member-a@example.com",
          "responseStatus": "needsAction"
        },
        {
          "email": "member-b@example.com",
          "responseStatus": "needsAction"
        }
      ]
    }
  ]
}

[mcp_tool_use] calendar / get_event
{
  "eventId": "[MASKED_EVENT_ID]"
}

[mcp_tool_result] calendar / get_event / success=true
{
  "start": {
    "dateTime": "2026-08-18T09:00:00+09:00",
    "timeZone": "Asia/Tokyo"
  },
  "end": {
    "dateTime": "2026-08-18T10:00:00+09:00",
    "timeZone": "Asia/Tokyo"
  },
  "summary": "社外打ち合わせA",
  "status": "confirmed"
}

[mcp_tool_use] gmail / create_draft
{
  "body": "〇〇さん\n\nお疲れ様です。\n\n本日8/17(月) 14:35〜15:00に予定しております「社内定例A」の件でご連絡です。\nあいにく体調がすぐれず、恐れ入りますが対面ではなくオンライン(リモート)でのご参加とさせていただけますと助かります。\n\n急なお願いで恐縮ですが、よろしくお願いいたします。",
  "subject": "「社内定例A」リモート開催のお願い(8/17 14:35〜)",
  "to": [
    "member-a@example.com"
  ]
}

[mcp_tool_result] gmail / create_draft / success=true
{
  "id": "[MASKED_DRAFT_ID]"
}

[mcp_tool_use] gmail / create_draft
{
  "body": "皆様\n\nお疲れ様です。\n\n8/19(水) 9:05〜10:00に予定しております「開発定例A」の件でご連絡です。\nあいにく体調がすぐれず、恐れ入りますが対面ではなくオンライン(リモート)でのご参加とさせていただけますと助かります。\n\n急なお願いで恐縮ですが、よろしくお願いいたします。",
  "subject": "「開発定例A」リモート開催のお願い(8/19 9:05〜)",
  "to": [
    "member-a@example.com",
    "member-b@example.com"
  ]
}

[mcp_tool_result] gmail / create_draft / success=true
{
  "id": "[MASKED_DRAFT_ID]"
}

作成に成功した下書き: 2件

今日(2026-08-17)から3日以内(2026-08-17〜08-19)に予定されている打ち合わせを確認しました。

参加者情報が判明している以下2件について、それぞれ下書きを作成しました(送信はしていません)。

| 会議名 | 日時 | 宛先 |
|---|---|---|
| 社内定例A | 8/17(月) 14:35〜15:00 | member-a@example.com |
| 開発定例A | 8/19(水) 9:05〜10:00 | member-a@example.com, member-b@example.com |

各メールとも「体調不良のため対面ではなくリモートでの参加に切り替えてほしい」旨を、会議名・日時に触れつつ簡潔に記載しています。

対象外とした予定:
- 「社外打ち合わせA」(8/18 9:00〜10:00)

上記予定はカレンダー上に参加者(メールアドレス)の登録がなく、宛先を推測で補うことができなかったため、下書き作成を見送りました。
宛先情報をご教示いただければ、追加で下書きを作成します。

注意点:

  • 資格情報は登録時には検証されません。実行時に初めて認証エラーとして表面化します。
  • mcp_server_urlsecret_name は作成後に変更できません。変えるときは資格情報をアーカイブして作り直します。1つのVaultに登録できる資格情報は20件までです。
  • リフレッシュに失敗すると vault_credential.refresh_failed イベントが出ます。原因が認可の失効なら、エンドユーザーに再認可してもらうしかありません。
  • 承認を挟む構成にする場合、承認待ちのセッションはタイムアウトしません。承認漏れは自然消滅せず溜まるので、滞留を見る仕組みを別に用意します。

まとめ

Managed Agents から Google Workspace MCP に繋ぎ、APIを1つも実装せずに便利な操作を実現しました。

定義したのは、接続先のURLと使えるツール(allowlist)、誰の代理で動くか(vault_id) のみです。

便利な機能ですが、安全な設計をする重要度は増しており、何を通して、何を塞ぐか、常に把握しておく必要があります。

elatt では、こうしたツール連携でお客さまの様々な事務作業を自動化しています。設計や事業のご相談などお気軽にお問い合わせからご連絡ください。

Google Workspace MCP を Managed Agents で動かす - 合同会社ふんどし