2026年8月28日金曜日

APEXアプリのレポートとフォームの操作を行うWebMCPツールを作成する

先日の記事「APEXアプリケーションのフォームをWebMCPに対応させる」にて、宣言型APIを使って従業員を登録するWebMCPツールを作成しました。その際に、APEXアプリケーション向けのWebMCPツールを作成するには、宣言型APIでは制限が多いことが分かりました。

本記事ではWebMCPの命令型APIを使って、従業員の検索と従業員の作成、変更、削除を行う4つのWebMCPツールを作成します。

以下のGIF動画で、作成した4つのWebMCPツールを実行しています。
  1. WebMCPツールsearch_employeesを呼び出し、ACCOUNTING部門の従業員を一覧します。
  2. WebMCPツールsearch_employeesを呼び出し、SALES部門の従業員を一覧します。
  3. 作成ボタンをクリックし、従業員の作成フォームを開きます。
  4. WebMCPツールregister_employeeを呼び出し、従業員を一人作成します。
  5. レポートの画面に戻ります。
  6. 作成した従業員を編集するフォームを開きます。
  7. WebMCPツールupdate_employeeを呼び出し、従業員の給与を8000に更新します。
  8. レポートの画面に戻ります。
  9. 作成した従業員を編集するフォームを開きます。
  10. WebMCPツールdelete_employeeを呼び出し、従業員を削除します。
  11. レポートの画面に戻ります。

WebMCPツールを実装するにあたって、以下の制約がありました。
  • WebMCPツールはdocumentに登録されるため、ツール内の処理でページを遷移するのは難しい(documentが変わるため)です。できないわけではないようですが、ツールの実行結果がnullになることがあるようです。その場合、ツール実行が成功したのか失敗したのか、WebMCPツールを呼び出したAIに伝わりません。そのため、従業員の編集フォームを開き、WebMCPツールによって従業員の作成、変更、削除を行った後にレポートのページに戻っていません。WebMCPツールの呼び出し元であるAIが、ページのナビゲーションを行ってくれることを期待しています。
  • 一般的に、APEXでは編集フォームはモーダル・ダイアログやドロワーとして実装します。モーダル・ダイアログやドロワーは親ページであるレポートのページとdocumentが同じになります。WebMCPツールの登録をフォームのページのページ・ロード時に実行すると、親ページのドキュメントにフォームを開けるたびに同じWebMCPツールが登録されます。回避策はあるようですが手間なので、単純にフォームのページを標準ページとして作成しています。レポートとフォームは別のページとなり、documentもそれぞれのページが持ちます。
WebMCPツールを登録するレポートとフォームのページは、先日の記事で作成したAPEXアプリケーションWebMCP Testに追加します。

ページの作成を開始します。


クラシック・レポートを選択します。

WebMCPツールでの検索に、対話モード・レポートや対話グリッドが持つ機能を実装するのはあまり現実的ではありません。ここでは、WebMCPツールの検索結果と画面上のレポートの表示を一致させることができるクラシック・レポートを選択します。


WebMCPツールのコードにページ番号が含まれています。レポートページ番号5フォームページ番号6としておくと、後でコードを変更しなくて済みます。

レポートページ名Employees - WebMCPとして、フォーム・ページを含めるオンにします。フォームページ名Employee - WebMCPとします。レポートもフォームもページ・モード標準を選択します。

データ・ソース表/ビューの名前EMPを指定します。

へ進みます。


主キー列1EMPNO(Number)です。ページの作成を実行します。


以上でレポートとフォームのページが作成されます。

作成されたレポートのページは以下です。


フォームのページは以下です。


最初にレポートのページにWebMCPツールsearch_employeesを作成します。

従業員名または部門名による完全一致を検索の条件とします。検索条件となる従業員名および部門名を入力するページ・アイテムと、検索を実行するボタンを作成します。従業員名のページ・アイテムはP5_ENAME、部門名のページ・アイテムはP5_DNAME、検索ボタンはSEARCHとします。

ページ・アイテムとボタンを配置するリージョンをWebMCP Search Toolとして作成します。タイプ静的コンテンツです。

作成したリージョンにページ・アイテムP5_ENAMEP5_DNAMEを作成します。タイプテキスト・フィールドです。両方のページ・アイテムで、詳細保存されていない変更の警告無視セッション・ステートストレージリクエストごと(メモリーのみ)に変更します。AIエージェントがページ・アイテムへ検索条件の設定した後、ページ遷移しようとすると警告されます。検索条件は無条件で破棄して良いので、警告が発生しないように無視します。

セッション・ステートストレージは、ページ・アイテムの値を永続化する必要がない場合は、無駄にリソースを使わないようにできるだけリクエストごと(メモリーのみ)にします。


ボタンSEARCHを作成し、トリガー・アクションとしてクラシック・レポートのリフレッシュを作成します。

識別アクションリフレッシュを選択します。影響を受ける要素選択タイプとしてリージョンを選び、リージョンとしてクラシック・レポートのリージョンEmployees - WebMCPを選びます。


クラシック・レポートに検索条件が反映されるようにします。

ソースタイプSQL問合せに変更し、SQL問合せとして検索条件を追加した以下のSELECT文を記述します。
select
    e.empno,
    e.ename,
    e.job,
    e.mgr,
    e.hiredate,
    e.sal,
    e.comm,
    e.deptno
from emp e
where (:P5_ENAME is null or e.ename = :P5_ENAME)
  and (
    :P5_DNAME is null
    or exists (
        select 1
        from dept d
        where d.deptno = e.deptno
            and d.dname = :P5_DNAME
    )
)
送信するページ・アイテムP5_ENAMEおよびP5_DNAMEを設定します。


以上で、従業員または部門名を指定して従業員を検索するページが作成できました。

ページを実行すると、手作業による検索動作を確認できます。


WebMCPツールsearch_employeesを作成します。

最初に検索結果をJSONで返すAjaxコールバックを作成します。名前はSEARCH_EMPLOYEESとします。ソースPL/SQLコードに以下を記述します。

クラシック・レポートと同じ検索結果(列FORM_URLは追加)をJSON配列で返します。
declare
  l_result json_object_t := json_object_t();
  l_employees clob;
begin
    select json_arrayagg(
        json_object(
            'EMPNO'    value e.empno,
            'ENAME'    value e.ename,
            'JOB'      value e.job,
            'MGR'      value e.mgr,
            'HIREDATE' value to_char(e.hiredate,'YYYY/MM/DD'),
            'SAL'      value e.sal,
            'COMM'     value e.comm,
            'DEPTNO'   value e.deptno,
            'FORM_URL' value apex_page.get_url(
                p_page      => 6,
                p_clear_cache => '6',
                p_items     => 'P6_EMPNO',
                p_values    => e.empno,
                p_plain_url => true
            )
            returning clob
        )
        returning clob
    )
into l_employees
from emp e
where (:P5_ENAME is null or e.ename = :P5_ENAME)
  and (
    :P5_DNAME is null
    or exists (
        select 1
        from dept d
        where d.deptno = e.deptno
            and d.dname = :P5_DNAME
    )
);

l_result := json_object_t();
l_result.put('success', true);
if l_employees is null then
    l_result.put('search_result', json_array_t());
else
    l_result.put('search_result', json_array_t(l_employees));
end if;

htp.p(l_result.to_clob());

exception
    when others then
        l_result := json_object_t();
        l_result.put('success', false);
        l_result.put('message', sqlerrm);

        htp.p(l_result.to_clob());
end;

このAjaxコールバックを呼び出すWebMCPツールsearch_employeesを、ページに登録します。ページ・ロード時に実行される動的アクションで、以下のJavaScriptを実行します。
(async () => {
  await document.modelContext.registerTool({
    name: 'search_employees',
    description: 'Lists employee information according to the specified conditions.',
    inputSchema: {
      type: 'object',
      properties: {
        ename: {
            type: 'string',
            description: 'Employee name to search for'
        },
        dname: {
            type: 'string',
            description: 'Department to which the employee belongs' 
        }
      }
    },
    execute: async ({ ename,dname }) => {
      try {
        apex.item('P5_ENAME').setValue(ename ?? '');
        apex.item('P5_DNAME').setValue(dname ?? '');
        apex.region('employees-report').refresh();

        return await apex.server.process(
          'SEARCH_EMPLOYEES',
          {
            pageItems: [
                'P5_ENAME',
                'P5_DNAME'
            ]
          }
        );
      } catch (error) {
        return {
          success: false,
          errorCode: 'REQUEST_ERROR',
          message: error.message || 'Employee search request failed.'
        };
      }
    }
  });
})();

WebMCPツールのJavaScriptコードの中より、クラシック・レポートのリフレッシュを実行しています。そのリフレッシュ先をemployees-reportとしているため、クラシック・レポートの詳細HTML DOM IDemployees-reportを設定します。


以上でWebMCPツールsearch_employeesが作成できました。Chrome拡張機能のWebMCP Toolを開き、search_employeesを実行できます。


次にフォームのページにWebMCPツールとして、register_employeeupdate_employeedelete_employeeを作成します。

AjaxコールバックとしてREGISTER_EMPLOYEEを作成します。ソースPL/SQLコードは以下です。
declare
  l_empno   emp.empno%type;
  l_result  json_object_t;
begin
  insert into emp (
    ename,
    job,
    mgr,
    hiredate,
    sal,
    comm,
    deptno
  )
  values (
    upper(:P6_ENAME),
    :P6_JOB,
    :P6_MGR,
    :P6_HIREDATE,
    :P6_SAL,
    :P6_COMM,
    :P6_DEPTNO
  )
  returning empno into l_empno;

  :P6_EMPNO := l_empno;

  l_result := json_object_t();
  l_result.put('success', true);
  l_result.put('empno', l_empno);
  l_result.put('message', 'Employee registered successfully.');

  htp.p(l_result.to_clob());

exception
  when others then
    l_result := json_object_t();
    l_result.put('success', false);
    l_result.put('message', sqlerrm);

    htp.p(l_result.to_clob());
end;

AjaxコールバックとしてUPDATE_EMPLOYEEを作成します。ソースPL/SQLコードは以下です。

APEXの標準プロセスによるアップデート処理では、チェックサムによる同時実行制御やロスト・ライトの対応が行われています。今回はそこまでの実装はせず、単純に受け取った値でUPDATE文を実行しています。
declare
    l_result  json_object_t;
begin
    update emp set
        ename = :P6_ENAME,
        job   = :P6_JOB,
        mgr   = :P6_MGR,
        hiredate  = :P6_HIREDATE,
        sal   = :P6_SAL,
        comm  = :P6_COMM,
        deptno = :P6_DEPTNO
    where empno = :P6_EMPNO;

    l_result := json_object_t();
    l_result.put('success', true);
    l_result.put('empno', :P6_EMPNO);
    l_result.put('message', 'Employee updated successfully.');

    htp.p(l_result.to_clob());

exception
    when others then
        l_result := json_object_t();
        l_result.put('success', false);
        l_result.put('message', sqlerrm);

        htp.p(l_result.to_clob());
end;

AjaxコールバックとしてDELETE_EMPLOYEEを作成します。ソースPL/SQLコードは以下です。
declare
    l_result  json_object_t;
begin
    delete from emp where empno = :P6_EMPNO;

    l_result := json_object_t();
    l_result.put('success', true);
    l_result.put('empno', :P6_EMPNO);
    l_result.put('message', 'Employee deleted successfully.');

    htp.p(l_result.to_clob());

exception
    when others then
        l_result := json_object_t();
        l_result.put('success', false);
        l_result.put('message', sqlerrm);

        htp.p(l_result.to_clob());
end;

これらのAjaxコールバックを呼び出すWebMCPツールをページに登録します。

従業員を作成するWebMCPツールregister_employeeは、ページ・アイテムP6_EMPNOがnullのときに作成します。そのため、アクションのクライアント側の条件タイプアイテムはnullを選択し、アイテムとしてP6_EMPNOを指定します。

ページ・ロード時に実行される動的アクションで、以下のJavaScriptを実行します。
(async () => {
  await document.modelContext.registerTool({
    name: 'register_employee',
    description: 'Register new employee information.',
    inputSchema: {
      type: 'object',
      properties: {
        ename: { type: 'string', description: 'employee name' },
        job: { type: 'string', description: 'job' },
        mgr: { type: 'string', description: 'empno of the manager' },
        hiredate: { type: 'string', description: 'hire date' },
        sal: { type: 'string', description: 'salary' },
        comm: { type: 'string', description: 'commission' },
        deptno: { type: 'string', description: 'departement number' }
      },
      required: [ 'ename','job','hiredate','sal','deptno' ]
    },
    execute: async ({ ename,job,mgr,hiredate,sal,comm,deptno }) => {
      try {
        apex.item('P6_ENAME').setValue(ename);
        apex.item('P6_JOB').setValue(job);
        apex.item('P6_MGR').setValue(mgr ?? '');
        apex.item('P6_HIREDATE').setValue(hiredate);
        apex.item('P6_SAL').setValue(sal);
        apex.item('P6_COMM').setValue(comm ?? '');
        apex.item('P6_DEPTNO').setValue(deptno);

        return await apex.server.process(
          'REGISTER_EMPLOYEE',
          {
            pageItems: [
                'P6_ENAME',
                'P6_JOB',
                'P6_MGR',
                'P6_HIREDATE',
                'P6_SAL',
                'P6_COMM',
                'P6_DEPTNO'
            ]
          }
        );
      } catch (error) {
        return {
          success: false,
          errorCode: 'REQUEST_ERROR',
          message: error.message || 'Employee registration request failed.'
        };
      }
    }
  });
})();

従業員情報を更新するWebMCPツールupdate_employeeと、従業員を削除するWebMCPツールdelete_employeeは、ページ・アイテムP6_EMPNOがnullではないのときに作成します。そのため、アクションのクライアント側の条件タイプアイテムはnullではないを選択し、アイテムとしてP6_EMPNOを指定します。

ページ・ロード時に実行される動的アクションで、以下のJavaScriptを実行します。
(async () => {
    /*
     * This tool updates the information for the employee already selected, 
     * so P6_EMPNO should not be included as a parameter.
     */
  await document.modelContext.registerTool({
    name: 'update_employee',
    description: 'Update existing employee information.',
    inputSchema: {
      type: 'object',
      properties: {
        ename: { type: 'string', description: 'employee name' },
        job: { type: 'string', description: 'job' },
        mgr: { type: 'string', description: 'empno of the manager' },
        hiredate: { type: 'string', description: 'hire date' },
        sal: { type: 'string', description: 'salary' },
        comm: { type: 'string', description: 'commission' },
        deptno: { type: 'string', description: 'departement number' }
      }
    },
    execute: async ({ ename,job,mgr,hiredate,sal,comm,deptno }) => {
      try {
        ename    != null && apex.item('P6_ENAME').setValue(ename);
        job      != null && apex.item('P6_JOB').setValue(job);
        mgr      != null && apex.item('P6_MGR').setValue(mgr);
        hiredate != null && apex.item('P6_HIREDATE').setValue(hiredate);
        sal      != null && apex.item('P6_SAL').setValue(sal);
        comm     != null && apex.item('P6_COMM').setValue(comm);
        deptno   != null && apex.item('P6_DEPTNO').setValue(deptno);

        return await apex.server.process(
          'UPDATE_EMPLOYEE',
          {
            pageItems: [
                'P6_EMPNO',
                'P6_ENAME',
                'P6_JOB',
                'P6_MGR',
                'P6_HIREDATE',
                'P6_SAL',
                'P6_COMM',
                'P6_DEPTNO'
            ]
          }
        );
      } catch (error) {
        return {
          success: false,
          errorCode: 'REQUEST_ERROR',
          message: error.message || 'Employee update request failed.'
        };
      }
    }
  });
  /*
   * This tool removes the employee currently selected in P6_EMPNO,
   * so no parameters need to be specified.
  */
  await document.modelContext.registerTool({
    name: 'delete_employee',
    description: 'delete existing employee information.',
    execute: async ({}) => {
      try {
        return await apex.server.process(
          'DELETE_EMPLOYEE',
          {
            pageItems: [
                'P6_EMPNO'
            ]
          }
        );
      } catch (error) {
        return {
          success: false,
          errorCode: 'REQUEST_ERROR',
          message: error.message || 'Employee delete request failed.'
        };
      }
    }
  });
})();

以上で、予定していた4つのWebMCPツールの作成は完了です。この記事の先頭のGIF動画で行っている操作を、WebMCPツールを使って実施できます。

今回作成したAPEXアプリケーションの、APEXlang形式のエクスポートを以下に置きました。
https://github.com/ujnak/APEXlang-exports/tree/main/webmcp-test

Oracle APEXのアプリケーション作成の参考になれば幸いです。

2026年8月27日木曜日

APEXアプリケーションのフォームをWebMCPに対応させる

最近はWebMCPと呼ばれる、WebアプリケーションをAIで操作できるようにする標準仕様が策定中のようです。Oracle APEXはWebアプリケーションを作成するツールですが、今のところ人がアプリケーションを操作することを想定しています。ただ、これからはAIによる操作も一般的になるでしょう。試しに簡単なフォームをAPEXで作成し、WebMCPのツールとして認識させてみました。WebMCPツールの作成には、JavaScriptで記述してツールを作成する方法(命令型API)とHTMLに属性を定義する宣言的な方法(宣言型API)があります。ページを宣言的に作成するツールであるAPEXに合わせて、宣言型APIを使ってAPEXのページをWebMCPに対応させてみます。

サンプル・データセットのEMP/DEPTに含まれる表EMPに従業員を入力するフォームを作り、Chromeに拡張機能として追加したWebMCP Toolを使って従業員を入力してみました。GeminiのAPIキーを設定すると、AIが生成した入力データを使ってツールを実行するようですが、お金がかかります。入力データとなるJSONは、決め打ちでInput Argumentsに与えています。


APEXアプリケーションをWebMCPツールとして呼び出せるようにする際に、以下の制約がありました。
  1. WebMCPツールの宣言型APIによる実装では、1つのWebMCPツールは1つのフォームとそのフォームの送信として定義されます。Oracle APEXのアプリケーションは1つのページにform要素は1つだけ含まれます(フォーム・リージョンは複数配置できますが、フォーム・リージョンはform要素ではありません)。結果としてAPEXアプリケーションの1ページに実装できるWebMCPツールは1つだけです。1ページに複数のWebMCPツールを含めるには、命令型APIで作成する必要があります。
  2. 上記に関連しますが、APEXではフォームにたいして複数のボタンを作成し、ボタンごとに動作を切り替えるように実装します。WebMCPツールでの操作は送信のみが望ましいため、APEXでよくある1つのフォームに作成、変更の確定、削除、キャンセルを紐づけるよりは、作成フォーム、変更フォーム、削除フォームとフォームを分けてアプリケーションを作成する必要がありそうです。1にも関連しますが、APEXでは1ページ1フォームであるため、WebMCPツールは作成ページ、変更ページ、削除ページのように、それぞれのページとして作成することになります。現実的には宣言型APIによる実装は諦め、命令型APIで1ページに複数のWebMCPツールを作成することになりそうです。
  3. WebMCPツールはフォームの送信(Submit)によって実行されます。APEXのページ送信の仕組みでは呼び出せません。正確にいうと呼び出すことができますが、実行結果を呼び出し元 - つまりLLMに返せません。そのため、WebMCPツールの送信処理はAjaxコールバックで実装する必要があります
  4. 入力フィールドに推奨値を設定するだけであれば、上記の制限は発生しません。AIが入力をアシストしてくれるでしょう。
ChromeでWebMCPを使う準備については、Chrome for Developersに掲載されているAlexandra KlepperさんによるWebMCPの記事で紹介されています。

Chrome for Developers - WebMCP

上記の記事で紹介されている、以下の2つの作業を実施しておきます。
  1. Chromeのフラグchrome://flags/#enable-webmcp-testingを有効にする。
  2. ChromeにModel Context Protocol Inspector拡張機能をインストールする。
作業を行うAPEXワークスペースに、サンプル・データセットのEMP/DEPTをインストールしておきます。

以下よりWebMCPツールとして呼び出せる、従業員の入力フォームの作成手順を紹介します。

最初に空のAPEXアプリケーションを作成します。名前WebMCP Testとします。


空のAPEXアプリケーションWebMCP Testが作成されます。

WebMCPツールを組み込むページを作成します。これは表EMPのフォームです。

ページの作成をクリックします。


フォームを選択します。


ページの名前Employeeページ・モード標準とします。データ・ソース表/ビューの名前EMPを選択します。

へ進みます。


主キー列1EMPNO(Number)です。ページの作成を実行します。

ページEmployeeが作成されます。


今回は宣言型APIでWebMCPツールを実装します。APEXと宣言型APIの組み合わせではWebMCPツールは1つしか実装できないため、従業員を作成するWebMCPツールのみを実装します。

従業員番号を手入力できるようにするため(APEXでは主キー列はデフォルトで非表示、チェックサムによる保護が有効)、ページ・アイテムP2_EMPNOタイプ数値フィールドに変更し、セキュリティセッション・ステート保護制限なしに変更します。


ページ・アイテムをWebMCPツールの入力として認識させるため、それぞれのページ・アイテムの詳細カスタム属性toolparamdescriptionを設定します。

ページ・アイテムP2_EMPNO: toolparamdescription="従業員番号"
ページ・アイテムP2_ENAME: toolparamdescription="従業員の名前"
ページ・アイテムP2_JOB: toolparamdescription="従業員の職種"
ページ・アイテムP2_MGR: toolparamdescription="マネージャーの従業員番号"
ページ・アイテムP2_HIREDATE: toolparamdescription="採用日"
ページ・アイテムP2_SAL: toolparamdescription="給与"
ページ・アイテムP2_COMM: toolparamdescription="手当"
ページ・アイテムP2_DEPTNO: toolparamdescription="所属している部門の部門番号"


APEXのフォームに複数のボタンが配置されていますが、これらのボタンはtype="button"です。WebMCPツールとしては、type="submit"のボタンが必要です。そのため、type="submit"のボタンを作成します。APEX標準のボタンとしては作成できないため、静的コンテンツのリージョンを作成し、HTMLでボタンを記述します。

リージョンの名前WebMCPとします。タイプ静的コンテンツを選択し、ソースHTMLコードに以下を記述します。

<button type="submit">Submit</button>


ボタンSubmitが作成できましたが、これはAIエージェント向けのボタンで人に見せる必要はありません。ページ上に要素は存在するが、表示はしない設定を追加します。

詳細HTML DOM IDwebmcp-regionカスタム属性hiddenを設定します。


本来であればこれで静的コンテンツのリージョンが非表示になるはずですが、APEXのテーマがhidden属性を上書きしている模様です。ページ・プロパティのCSSインラインに以下を記述し、対象のリージョンを非表示にします。
#webmcp-region[hidden] {
  display: none !important;
}

以上でWebMCP向けのボタンSubmitの作成と、そのボタンを非表示にできました。

従業員の作成を行うAjaxコールバックを作成します。名前はREGISTER_EMPLOYEEとします。

ソースのPL/SQLコードに以下を記述します。受け取ったページ・アイテムの値を表EMPに挿入しているだけの、簡単な処理です。



ページ・ロード時に、WebMCPツールを作成するJavaScriptのコードを実行します。

ページのロードで実行される動的アクションRegister WebMCP Toolとして作成します。その動的アクションにTRUEアクションとしてJavaScriptコードの実行を作成します。

TRUEアクションの設定コードに以下を記述します。



JavaScriptのコードで、WebMCPの宣言型APIとなる属性であるtoolnameおよびtooldescriptionをform要素に追加しています。また、フォームが送信されるときに実行されるAPEX組み込みの処理を、WebMCPツールとして必要な処理に置き換えています。

以上でアプリケーションは完成です。

必ずしも必要ではありませんが、WebMCPツールで操作した従業員データを編集できるように、対話モード・レポートとフォームのページを追加しておくと便利でしょう。

ページの作成を実行します。


タイプとして対話モード・レポートを選択します。これ以降の作業は、一般的な対話モード・レポートとフォームの作成なので、手順は省略します。


最初はページ・アイテムへの入力に留めます。JavaScriptのコードで属性toolautosubmitを追加している部分をコメントアウトします。
// Comment out the next line when a user should review the form and submit it manually.
// formElement.setAttribute('toolautosubmit', '');
Employeeのフォームを開き、拡張機能のWebMCP Toolを実行します。

Toolとしてregister_employeeが見つかっています。


Input Argumentsに以下のJSONを入力します。名前などの入力値は変更してテストされると良いでしょう。
{
 "P2_EMPNO": "9992",
 "P2_ENAME": "YUJI",
 "P2_JOB": "ANALYST",
 "P2_MGR": "",
 "P2_HIREDATE": "2028/08/26",
 "P2_SAL": "8000",
 "P2_COMM": "20",
 "P2_DEPTNO": "10"
}
Execute Toolをクリックします。


Input Argumentsに与えた値がフォームに入力されます。toolautosubmitをコメントアウトしているため、WebMCPツールの実行は行われません。


toolautosubmitのコメントを外し、元に戻します。
// Comment out the next line when a user should review the form and submit it manually.
formElement.setAttribute('toolautosubmit', '');
先ほどと同じ操作を行い、Execute Toolをクリックします。

今度はAjaxコールバックREGISTER_EMPLOYEEが呼び出され、従業員が作成されます。Ajaxコールバックのレスポンスが、そのままWebMCPツールが受け取ったレスポンスとして表示されています。


もう一度Execute Toolをクリックすると、同じ従業員番号の従業員が存在するため、一意制約違反が発生して従業員の作成に失敗します。


対話モード・レポートのページを開くと、WebMCPツールを呼び出して作成した従業員の存在を確認できます。


JavaScriptでコーディングするなら宣言型APIを使うより、最初から命令型APIで記述しても良いのでは?と思います。特にAPEXでは1ページに1フォームという制限がある上に、APEX組み込みのフォームの送信処理がWebMCPツールの処理に干渉します。

本記事で記述したコードの多くは命令型APIによる実装に流用できます。AjaxコールバックによるWebMCPツールの実装は命令型APIでも同じです。

今回の記事は以上になります。

今回作成したAPEXアプリケーションの、APEXlang形式のエクスポートを以下におきました。
https://github.com/ujnak/APEXlang-exports/tree/main/webmcp-test

本記事に掲載しているPL/SQLおよびJavaScriptのコードは、OpenAI GPT 5.6 Terraで生成しました。

Oracle APEXのアプリケーション作成の参考になれば幸いです。

2026年8月26日水曜日

Oracle Backend for Firebaseのスナップショット・リスナーを使用する

Oracle Backend for Firebase(Fusabase)ではCloud Firestoreと同様にスナップショット・リスナーを実装できます。Oracle Backend for FirebaseのLiveLabsとして、JavaScriptiOSAndroidのSDKを使用したワークショップが提供されていますが、この中のAndroid SDKを使ったワークショップは、スナップショット・リスナーを実装しています。

そのため、Androidのワークショップを行うとスナップショット・リスナーを実装したクライアントが作成されます。本記事では、Androidクライアントを使用してスナップショット・リスナーの動作を確認します。Android SDKのスナップショット・リスナーについては、Andorid SDKのドキュメントのSnapshot listenersで紹介されています。

LiveLabsのワークショップでは使用されていませんが、JavaScript SDK、iOS SDKでもスナップショット・リスナーを実装できます。これらもCloud Firestore APIと同様の形式で、JavaScript SDKのドキュメントではLive listeners、iOS SDKのドキュメントではSnapshot listenersとして紹介されています。

Oracle Backend for Firebase(Fusabase)のスナップショット・リスナーを使用するにあたって、以下の点に注意が必要でした。

使用しているSDKはWeb, iOS, Androidともに、バージョンv26.2.0です。
  • Oracle Backend for Firebase(Fusabase)はローカル・キャッシュを実装していません。DocumentSnapshotおよびQuerySnapshotのメタデータのisFromCacheまたはfromCacheはつねにfalseを返します。
  • スナップショット・リスナーが受け取る更新イベントを発生させる機構として、ロング・ポーリングとWebSocketの2種類があります。設定ファイルfusabase-config.jsonで、useSocket(またはuse_socket - SDKによって異なります)をtrueにするとWebSocketになります。falseのときはロング・ポーリングです。
  • ロング・ポーリング(long polling)は名称の通り、クライアント側から定期的にサーバーに問い合わせを行い、変更を見つけた時にスナップショット・リスナーに更新イベントを送ります。
  • ロング・ポーリングの場合、設定ファイルfusabase-config.jsonで、long_polling_intervalによりポーリング間隔を調整できます。単位は秒です。省略時は29秒、JavaScript SDKでは設定可能な値は5から300秒に制限されています。iOS SDKに制限はないようです。Android SDKでは10秒で固定されています。
  • WebSocket場合はサーバー側の表にOracle Continuous Query Notification(CQN)が構成され、CQNで検知された更新がWebSocket経由でクライアントに送られます。useSocket(use_socket)がtrueの場合、ORDSとクライアント間でWebSocketによって接続されます。
  • useSocketがtrueすなわち、Oracle CQNとWebSocketが通知に使用される場合、データ更新からクライアントへの通知までにかかる時間は1秒前後です。ロング・ポーリングの場合は、long_polling_intervalの設定に依存します。間隔を短くすると通知も速くなりますが、サーバー側への負荷は高くなります。
WebSocketによるサーバーからの変更分のプッシュ通知は魅力的ですが、現時点で色々な不備が見つかっています。そのため、設定としてはロング・ポーリングを選択することをお勧めします。ロング・ポーリングの場合は、新たに取得したコレクションとメモリに残っているスナップショットを比較して差分を生成しています。スナップショット・リスナーが有効である限り、繰り返しサーバーに問い合わせが発行されることに注意が必要です。サーバーへの負荷を考慮すると、スナップショット・リスナーの活用は慎重に行う方が良いでしょう。

LiveLabsのAndroid SDKでのワークショップで、スナップショット・リスナーを実装しているのは、Lab 4: Read Recipe DataのTask 2: Subscribe to the recipes collectionの以下のコードです。コレクションrecipesにスナップショット・リスナーを構成しています。
    /**
     * Subscribes to the {@code recipes} collection. When {@code category}
     * is non-null, applies a {@code whereEqualTo("category", ...)} filter.
     * Sorts by {@code createdAt} descending and limits to 24 results.
     *
     * <p>TODO Lab 4 — Task 1: Build the query and call
     * {@code addSnapshotListener(...)}. Save the returned
     * {@link ListenerRegistration} into {@link #recipesListener} so it can
     * be removed later in {@link #stopListeningToRecipes()}.
     */
    public void startListeningToRecipes(@Nullable String category) {
        // TODO Lab 4
        Query q = db.collection("recipes");
        if (category != null) {
            q = q.whereEqualTo("category", category);
        }
        q = q.orderBy("createdAt", Query.Direction.DESCENDING).limit(24);

        recipesListener = q.addSnapshotListener((snapshot, error) -> {
            if (error != null) {
                errorMessage.postValue(error.getMessage());
                return;
            }
            if (snapshot == null) return;
            List<Recipe> mapped = new ArrayList<>();
            for (QueryDocumentSnapshot doc : snapshot) {
                Recipe r = doc.toObject(Recipe.class);
                if (r != null) {
                    r.setId(doc.getId());
                    mapped.add(r);
                }
            }
            recipes.postValue(mapped);
        });
    }
Lab 4のTask 4: Subscribe to a single recipe and its ratingsでは、単一のレシピとそれに紐づくサブ・コレクションratingsにスナップショット・リスナーを構成しています。
    /**
     * Subscribes to a single recipe document. Drives the recipe detail
     * screen.
     *
     * <p>TODO Lab 4 — Task 3: Implement using
     * {@code db.collection("recipes").document(recipeId).addSnapshotListener(...)}.
     * Also start the ratings subcollection listener so the detail screen
     * stays in sync when ratings are added in Lab 5.
     */
    public void startListeningToRecipe(String recipeId) {
        // TODO Lab 4
        DocumentReference doc = db.collection("recipes").document(recipeId);

        recipeDetailListener = doc.addSnapshotListener((snapshot, error) -> {
            if (error != null) {
                errorMessage.postValue(error.getMessage());
                return;
            }
            if (snapshot == null || !snapshot.exists()) return;
            Recipe r = snapshot.toObject(Recipe.class);
            if (r != null) {
                r.setId(snapshot.getId());
                currentRecipe.postValue(r);
            }
        });

        ratingsListener = doc.collection("ratings").addSnapshotListener((snapshot, error) -> {
            if (error != null) {
                errorMessage.postValue(error.getMessage());
                return;
            }
            if (snapshot == null) return;
            List<Rating> mapped = new ArrayList<>();
            for (QueryDocumentSnapshot d : snapshot) {
                Rating r = d.toObject(Rating.class);
                if (r != null) {
                    r.setId(d.getId());
                    mapped.add(r);
                }
            }
            currentRatings.postValue(mapped);
        });
    }
以下より、RecipeShareのAndroidアプリケーションに実装されたスナップショット・リスナーの動作を確認します。


通常のコレクションでの確認



通常のコレクション(Relational to collection mapping - JSON Duality Viewを使わないコレクション)での更新通知を確認します。

app/fusabase-config.jsonにuseSocket: falseとenableLogging: trueの設定を加えます。
{
    "schema": "testuser",
    "app_name": "com.oracle.fusabase.recipeshare",
    "app_type": "ANDROID",
    "app_id": "59C27E98507C546FE063020012ACBFD3",
    "objs_type": "dbfs",
    "project_id": "59C1663EAB6F13DFE063020012AC5621",
    "storage_bucket": "dbfs_BQYKZXSFPIUUWSH",
    "auth_type": "base",
    "auth_id": "59C1663EAB7313DFE063020012AC5621",
    "ords_host": "http://10.0.2.2:8181/ords/testuser/",
    "useSocket": false,
    "enableLogging": true
}
Build a Recipe App with Oracle Backend for FirebaseのAndroid SDKのワークショップのLab 5: Write Recipe Data, Task 3: Run and verifyを実施し、Garlic Butter Shrimpのレシピが登録された状態です。


コレクションを操作するクライアントとして、fusabase-cliを使用します。fusabase-cliの構成方法については記事「Oracle Backend for FirebaseのCLIインターフェースfusabase-cliを使用する」で紹介しています。

タイトルがGarlic Butter Shrimpのレシピを使用して、fusabase-cliで以下の操作を行います。Androidアプリはデータの変更を見つけて、自動で画面を更新します。
  1. Garlic Butter Shrimpを検索しドキュメントIDとJSON本文を取得する。
  2. Garlic Butter Shrimpのレシピを削除する。AndroidアプリからGarlic Butter Shrimpが削除されることを確認する。
  3. Garlic Butter ShrimpのJSON本文を使って、fusabase-cliよりレシピを登録する。AndroidアプリにGarlic Butter Shrimpが表示されることを確認する。
  4. AndroidアプリでGarlic Butter Shrimpを選択する。
  5. Garlic Butter ShrimpのJSON本文を変更し、fusabase-cliよりレシピを更新する。AndroidアプリのGarlic Butter Shrimpの表示が更新されることを確認する。
レシピGarlic Butter Shrimpを検索します。以下の内容のファイルをquery.jsonとして作成します。
{
  "path": [ "recipes" ],
  "conditions": [
    { "field": "title", "op": "=", "value": "Garlic Butter Shrimp" }
  ],
  "limit": 1,
  "explicitOrder": [
    { "field": "createAt", "direction": "asc" }
  ],
  "aggregate": []
}
fusabase-cliを使って検索します。

npx fusabase database query --path=query.json

Document IDDOCUMENT本文が得られます。

fusabase % npx fusabase database query --path=query.json                        

Trying to fetch document..{ ret: [ { osons: [Object] } ] }

List of OID within the collection:

  Document ID                        VERSION   DOCUMENT                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  

  59D961B5641AE3F8E063020012ACEA5D             {"ingredients":["1 lb large shrimp","4 tbsp butter","4 cloves garlic","1/4 cup parsley","1 lemon"],"title":"Garlic Butter Shrimp","averageRating":0,"instructions":"Melt butter in a skillet over medium-high heat. Add minced garlic and cook for 30 seconds. Add shrimp and cook 2-3 minutes per side until pink. Finish with chopped parsley and a squeeze of lemon.","ratingCount":0,"description":"Quick weeknight dinner with shrimp tossed in garlic butter and parsley.","category":"Dinner","ownerId":"","createdAt":1787635746671,"prepTime":20,"createdBy":""} 

fusabase % 


得られたDocument IDを指定して、レシピGarlic Butter Shrimpを削除します。

npx fusabase database delete recipes/[Document ID]

fusabase % npx fusabase database delete recipes/59D961B5641AE3F8E063020012ACEA5D


Path is : recipes/59D961B5641AE3F8E063020012ACEA5D

Collection delete Successfully:

{"message":"document deleted successfully!"}

fusabase % 


Androidアプリのレシピの一覧よりGarlic Butter Shrimpが削除されます。


Garlic Butter Shrimpのレシピを登録するファイルrecipe.jsonを作成します。dataには、検索結果のDOCUMENTを与えます。
{
  "path": [
    "recipes"
  ],
  "data": {
    "ingredients": [
      "1 lb large shrimp",
      "4 tbsp butter",
      "4 cloves garlic",
      "1/4 cup parsley",
      "1 lemon"
    ],
    "title": "Garlic Butter Shrimp",
    "averageRating": 0,
    "instructions": "Melt butter in a skillet over medium-high heat. Add minced garlic and cook for 30 seconds. Add shrimp and cook 2-3 minutes per side until pink. Finish with chopped parsley and a squeeze of lemon.",
    "ratingCount": 0,
    "description": "Quick weeknight dinner with shrimp tossed in garlic butter and parsley.",
    "category": "Dinner",
    "ownerId": "",
    "createdAt": 1787635746671,
    "prepTime": 20,
    "createdBy": ""
  }
}
fusabase-cliを使ってレシピを追加します。

npx fusabase database add --path=recipe.json

fusabase % npx fusabase database add --path=recipe.json

{"path":["recipes"],"data":{"ingredients":["1 lb large shrimp","4 tbsp butter","4 cloves garlic","1/4 cup parsley","1 lemon"],"title":"Garlic Butter Shrimp","averageRating":0,"instructions":"Melt butter in a skillet over medium-high heat. Add minced garlic and cook for 30 seconds. Add shrimp and cook 2-3 minutes per side until pink. Finish with chopped parsley and a squeeze of lemon.","ratingCount":0,"description":"Quick weeknight dinner with shrimp tossed in garlic butter and parsley.","category":"Dinner","ownerId":"","createdAt":1787635746671,"prepTime":20,"createdBy":""}}

Document Added Successfully!!

{"OID":"59D961B5641BE3F8E063020012ACEA5D","VERSION":1}

fusabase % 


Androidアプリのレシピの一覧に、再度Garlic Butter Shrimpが表示されます。


Garlic Butter Shrimpのレシピを開きます。


Garlic Butter Shrimpのレシピを更新するために、以下のファイルrecipe-upd.jsonを作成します。pathに更新対象のドキュメントIDを追加し、dataを更新します。今回はtitleにUPDATEを追加しています。
{
  "path": [
    "recipes",
    "59D961B5641BE3F8E063020012ACEA5D"
  ],
  "data": {
    "ingredients": [
      "1 lb large shrimp",
      "4 tbsp butter",
      "4 cloves garlic",
      "1/4 cup parsley",
      "1 lemon"
    ],
    "title": "Garlic Butter Shrimp - UPDATE",
    "averageRating": 0,
    "instructions": "Melt butter in a skillet over medium-high heat. Add minced garlic and cook for 30 seconds. Add shrimp and cook 2-3 minutes per side until pink. Finish with chopped parsley and a squeeze of lemon.",
    "ratingCount": 0,
    "description": "Quick weeknight dinner with shrimp tossed in garlic butter and parsley.",
    "category": "Dinner",
    "ownerId": "",
    "createdAt": 1787635746671,
    "prepTime": 20,
    "createdBy": ""
  }
}
fusabase-cliを使ってレシピを更新します。

npx fusabase database upd --path=recipe-upd.json

fusabase % npx fusabase database upd --path=recipe-upd.json 

{"path":["recipes","59D961B5641BE3F8E063020012ACEA5D"],"data":{"ingredients":["1 lb large shrimp","4 tbsp butter","4 cloves garlic","1/4 cup parsley","1 lemon"],"title":"Garlic Butter Shrimp - UPDATE","averageRating":0,"instructions":"Melt butter in a skillet over medium-high heat. Add minced garlic and cook for 30 seconds. Add shrimp and cook 2-3 minutes per side until pink. Finish with chopped parsley and a squeeze of lemon.","ratingCount":0,"description":"Quick weeknight dinner with shrimp tossed in garlic butter and parsley.","category":"Dinner","ownerId":"","createdAt":1787635746671,"prepTime":20,"createdBy":""}}

Document Updated Successfully!!

{"OID":"59D961B5641BE3F8E063020012ACEA5D","VERSION":3}

fusabase %


表示されているレシピのタイトルが更新されます。


以上で、異なるアプリケーションによるデータの変更に追従して、Androidアプリケーションの画面の表示が更新されることが確認できました。


Relational to collection mappingによるコレクションでの確認



通常のコレクションの代わりにRelational to collection mappingで作成したコレクションを使用して、スナップショット・リスナーの動作を確認します。

表RECIPESおよびRATINGSの作成およびRelational to collection mappingによるコレクションの作成とAndroidアプリケーションの変更手順については、記事「Oracle Backend for FirebaseのRelational to collection mappingを使ってJSON Duality Viewをコレクションとして使用する」にて紹介しています。

今回はデータがリレーショナル表であるRECIPESとRATINGSに保存されるため、データの変更にfusabase-cliの代わりに、これらの表を操作するAPEXアプリケーションを作成します。APEXとOracle Backend for Firebaseを共存させる環境については、記事「Oracle APEXが構成済みのデータベースにOracle Backend for Firebaseを構成する」で紹介しています。

通常のコレクションのときと同様に、Build a Recipe App with Oracle Backend for FirebaseのAndroid SDKのワークショップのLab 5: Write Recipe Data, Task 3: Run and verifyを実施し、Garlic Butter Shrimpのレシピが登録された状態まで作業を進めます。


データベースにはAPEXワークスペースとしてAPEXDEV、ワークスペースに紐づくスキーマとしてAPEXDEVが作成済みとします。Oracle Backend for Firebaseのプロジェクトが構成されているスキーマ(これまでの手順通りであればTESTUSER)に表RECIPESと表RATINGSがあります。これらの表を操作する権限を、スキーマAPEXDEVに与えます。

SQLclでスキーマTESTUSERに接続し、以下のコマンドを実行します。

grant all on recipes to apexdev;
grant all on ratings to apexdev;

SQL> grant all on recipes to apexdev;


Grantが正常に実行されました。


SQL> grant all on ratings to apexdev;


Grantが正常に実行されました。


SQL> 


APEXのワークスペースにサインインし、空のアプリケーションを作成します。

アプリケーションの名前RecipeShareとします。


アプリケーションRecipeShareが作成されます。ページの作成を開始します。


表RECIPESとRATINGSを編集するページを作成します。2つの表の編集画面を一度で作成できる、マスター・ディテールを選択します。


スタイルとしてドリルダウンを選択します。


マスターページ定義名前Recipesとします。マスターデータ・ソース表/ビューの所有者にOracle Backend for Firebaseのプロジェクトを構成したスキーマ、今回の作業ではTESTUSERを選択します。表/ビューの名前にマスター表にあたる表RECIPESを選択します。

へ進みます。


主キー列1ID (Number)フォームのナビゲーションの順序CREATED_AT(Date)を選択します。

へ進みます。


ディテール・ページ定義名前Ratingsとします。表/ビューの所有者は表RACIPESと同じくTESTUSER表/ビューの名前RATINGSを選択します。

へ進みます。


ディテール主キー主キー列1としてID(Number)を選択します。マスタ・ディテール外部キーID -> RECIPE_IDを選択します。

以上で、ページの作成を実行します。


表RECIPESとRATINGSの編集を行う、マスター・ディテールのページが作成できました。


ページを実行し、レシピGarlic Butter Shrimpの編集画面を開きます。


レシピGarlic Butter Shrimpの編集画面が開きます。


AndroidアプリケーションでレシピGarlic Butter Shrimpを開いておきます。


APEXアプリケーションに戻りページを下スクロールし、レシピに評価を追加します。

Authortest@example.comRating5Created At本日の日付CommentYum!を入力します。

以上で、変更の適用をクリックします。


表RATINGSに1行追加されました。


Androidアプリケーションに戻ると、APEXアプリから入力した評価が表示されていることが確認できます。


Oracle Backend for Firebaseのアプリケーションはコレクションrecipesとratingsを操作していますが、Relational to collection mappingにより、それらのコレクションはJSON Duality Viewを介してリレーショナル表RECIPESとRATINGSの操作になります。

APEXアプリケーションはそれらの実体である表RECIPESとRATINGSを操作します。両方のアプリケーションが同じ表を操作することにより、APEXアプリで操作したデータがネイティブのAndroidアプリに反映されます。

今回の記事は以上になります。