diff --git a/.changeset/action-mcp-tool-and-defer-loading.md b/.changeset/action-mcp-tool-and-defer-loading.md new file mode 100644 index 0000000000..8f345d5e93 --- /dev/null +++ b/.changeset/action-mcp-tool-and-defer-loading.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add `mcpTool` and `important` to `defineAction`, so an action declares its external-agent exposure and its first-request tool slot beside itself instead of in a plugin-level name list. `mcpTool` defaults to `agentTool`, so hiding an action from the agent hides it from outside agents too; declaring it overrides that inheritance in both directions. `mcpTool: false` hides an action from every MCP tier and the direct A2A surface (including the `--full-catalog` opt-in) while the in-app agent keeps calling it, `mcpTool: true` is the action-owned form of `mcp.connectorCatalog` membership, and `agentTool: false` with `mcpTool: true` makes an action MCP-only — external agents get it, the app's own agent does not. `deferLoading: false` keeps an action in the agent's first tool list and narrows the derived default to the actions that opted out of deferral, the action-owned form of `initialToolNames`; `deferLoading: true` pushes one behind `tool-search`. Both name lists keep working, so an app can migrate one action at a time. diff --git a/.github/workflows/auto-merge-version-packages.yml b/.github/workflows/auto-merge-version-packages.yml index ea4cfe282f..c84552879d 100644 --- a/.github/workflows/auto-merge-version-packages.yml +++ b/.github/workflows/auto-merge-version-packages.yml @@ -15,8 +15,9 @@ name: Auto-merge Version Packages PR # with `bypass_mode: always`, so `gh pr merge --admin` skips the # in-progress required checks. # -# The merge to main fires auto-publish.yml only for a marked, manually -# requested stable release. Ordinary main pushes remain nightly-only. +# The merge to main fires auto-publish.yml again; that run finds no pending +# changesets and publishes the bumped packages to npm `latest`. Its +# `[stable-release]` marker keeps the nightly snapshot job off this merge. on: pull_request: diff --git a/.github/workflows/auto-publish.yml b/.github/workflows/auto-publish.yml index bba4982ee7..1f0b077edc 100644 --- a/.github/workflows/auto-publish.yml +++ b/.github/workflows/auto-publish.yml @@ -1,12 +1,21 @@ name: Publish packages -# npm packages have two deliberately separate release trains: +# npm packages have two separate release trains: # # 1. Every matching ordinary merge to `main` publishes a `nightly` snapshot. # It never changes the repository or moves npm's `latest` dist-tag. -# 2. A maintainer dispatches this workflow with a patch/minor/major choice. -# That creates the normal all-package Version Packages PR. Its marked -# merge is the only path that publishes a stable release. +# 2. Stable releases ride changesets. A matching merge with pending +# changesets opens or updates the Version Packages PR (auto-merged by +# `auto-merge-version-packages.yml`); that PR's merge consumes the +# changesets and publishes to `latest`. A maintainer can also dispatch +# this workflow with a patch/minor/major choice to force an all-package +# release without waiting for a changeset to arrive. +# +# Both stable paths produce a `[stable-release]`-marked merge commit. That +# marker exists to keep the nightly job off the release merge — it is not a +# gate on stable publishing, and gating the release job on it deadlocks the +# train, because the marker can only ever appear on a PR the release job +# itself opens. # # Desktop-app publishing is OUT of changesets - it ships binaries via # electron-builder, not npm. Production desktop releases are dispatched from @@ -187,16 +196,18 @@ jobs: release: name: Prepare or publish stable npm packages - # Ordinary main pushes are nightly-only. A manual dispatch creates the - # marked Version Packages PR; only that PR's merge can enter stable npm - # publication. A downstream redispatch only re-sends a notification for - # versions that are already on npm, so it must not re-enter build/publish. - if: | - !inputs.redispatchDownstream && - ( - github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && contains(github.event.head_commit.message, '[stable-release]')) - ) + # Runs on every matching main push, which is what keeps the changeset train + # moving: with pending changesets `changesets/action` opens or updates the + # Version Packages PR, and with none it publishes whatever that PR's merge + # bumped. Gating this job on `[stable-release]` (or on a dispatch) stops the + # PR from ever being opened, so no merge can ever carry the marker: stable + # publishing stalls silently while nightly keeps going green, and changesets + # pile up unconsumed. That is not hypothetical — it held npm `latest` at + # 0.169.1 for three days behind 43 unreleased changesets. + # + # A downstream redispatch only re-sends a notification for versions that are + # already on npm, so it must not re-enter build/publish. + if: ${{ !inputs.redispatchDownstream }} runs-on: ubuntu-latest environment: npm-publish permissions: @@ -289,15 +300,14 @@ jobs: notify-downstream: name: Notify downstream repos needs: [release] - # A normal main push has no stable release job. Manual dispatches (including - # redispatch) and marked stable merges still need a written notification - # result even when the release job fails or is intentionally skipped. - if: | - always() && - ( - github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && contains(github.event.head_commit.message, '[stable-release]')) - ) + # always(): `release` is skipped on a redispatch and may fail on a bad + # publish; both cases still need a written answer about the notification. + # Do not narrow this to marked pushes either — `release` publishes whenever + # no changesets are pending, which is not always a merge carrying the + # marker, and a notification job that skips on the run that published is + # the exact silent failure the paragraph above describes. The body already + # writes "published nothing" and exits 0, so an ordinary push is cheap. + if: always() runs-on: ubuntu-latest permissions: {} steps: diff --git a/packages/core/docs/content/actions-access-control.mdx b/packages/core/docs/content/actions-access-control.mdx index bd5a019af4..29edc49214 100644 --- a/packages/core/docs/content/actions-access-control.mdx +++ b/packages/core/docs/content/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "Access & Authorization" -description: "Who can call an action: exposure flags (agentTool, toolCallable), ctx-based scoping, accessFilter/assertAccess, and the authorize guard." +description: "Who can call an action: exposure flags (agentTool, mcpTool, toolCallable), ctx-based scoping, accessFilter/assertAccess, and the authorize guard." --- # Access & Authorization -Five settings decide who can call an action: the agent, the frontend, an extension, an outside API caller, or nobody without a human's approval first. Most actions never need to touch any of these, since the defaults already fit normal UI-and-agent use. +Six settings decide who can call an action: the agent, the frontend, an extension, an outside API caller, or nobody without a human's approval first. Most actions never need to touch any of these, since the defaults already fit normal UI-and-agent use. ### Exposure flags {#exposure-flags} -All five flags default to the permissive value, so you only set one when you need to tighten a specific surface. The table below is a quick summary. The sections after it add the one detail each flag needs. +All six default to the permissive value — `mcpTool` by inheriting `agentTool` — so you only set one when you need to tighten a specific surface. The table below is a quick summary. The sections after it add the one detail each flag needs. | Flag | Default | Restrictive value → who can still call | Typical use | | --------------- | ------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------- | | `agentTool` | `true` | `false` → UI, HTTP, and CLI only (hidden from the model, MCP, and A2A) | UI-only or programmatic actions that shouldn't take up a tool slot | +| `mcpTool` | `agentTool` | `false` → your own app's agent only (hidden from MCP and A2A) | Actions that need an in-app screen or session on the other end | | `toolCallable` | `true` | `false` → everything **except** the sandboxed extension iframe bridge (403) | Sensitive account or org changes (delete account, change membership) | | `publicAgent` | off (private) | `{ expose: true }` → adds the action to **public** MCP/A2A/OpenAPI surfaces | Safe read or ingest tools that don't require authentication | | `needsApproval` | `false` | `true` → the agent **pauses**, and a human must approve the specific call | Consequential side effects (send email, charge a card, delete) | | `authorize` | none | a guard function → only callers it accepts, on **every** surface | Operations restricted to a role (per-app RBAC, admin-only ops) | -These flags are independent, so setting one doesn't change the others. `agentTool` controls what the model sees, see [Hide from the model](#agent-tool) below. `toolCallable` controls only the extension iframe, see [Block extension iframes](#tool-callable) below. `publicAgent` adds an opt-in public surface, and a public web route never implies public tool exposure, see [Expose to public agents](#public-agent) below. `needsApproval` gates execution after the call is already made, see [Require human approval](#needs-approval) below. `authorize` decides whether this caller may run the action at all, on every dispatch path, before `run()` is entered, see [Restrict callers by role](#authorize) below. +These flags are independent, with one exception: an undeclared `mcpTool` inherits `agentTool`. `agentTool` controls what the model sees, see [Hide from the model](#agent-tool) below. `mcpTool` narrows that to outside agents only, see [Hide from external agents](#mcp-tool) below. `toolCallable` controls only the extension iframe, see [Block extension iframes](#tool-callable) below. `publicAgent` adds an opt-in public surface, and a public web route never implies public tool exposure, see [Expose to public agents](#public-agent) below. `needsApproval` gates execution after the call is already made, see [Require human approval](#needs-approval) below. `authorize` decides whether this caller may run the action at all, on every dispatch path, before `run()` is entered, see [Restrict callers by role](#authorize) below. ## Hide from the model {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ Use this when you add a UI-only or purely programmatic action, or when the UI stops using an action that would otherwise stay exposed to the model. +## Hide from external agents {#mcp-tool} + +`agentTool` is all-or-nothing: an action is a tool for every agent, or for none. `mcpTool` splits that in two, so an action can stay a normal tool for your own app's agent while never reaching Claude, Cursor, or a sibling app over MCP and A2A: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +Use it for actions whose result only means something with your app's UI, an open session, or the current selection in front of the caller. `mcpTool` can only narrow `agentTool`, never widen it: an action with `agentTool: false` stays hidden everywhere. It governs the external tool surface itself, not what your own agent may do while answering an `ask_app` question — that run is your agent's, and it keeps its whole tool list. + +Left undeclared, `mcpTool` follows `agentTool` rather than defaulting to a flat `true`, so hiding an action from the agent hides it from outside agents too and one flag stays one decision. + +The opposite value has a second job. External agents are served a small, curated catalog by default rather than your whole action registry, and `mcpTool: true` is how an action declares itself part of it: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +This is the same catalog as the plugin's `mcp: { connectorCatalog: [...] }` list, declared on the action instead of in a separate array that has to be kept in sync as actions are renamed. Both forms work, and an app can move over one action at a time. Everything left out stays discoverable through `tool-search`. + +Pairing the two flags the other way — `agentTool: false` with `mcpTool: true` — makes an action **MCP-only**: outside agents get it, your own app's agent never sees it. Use it for the export, sync, or handoff an outside tool needs and your own agent has no reason to call. + +Membership is not permission. An external caller still has to pass the OAuth scope check, the `externalAgents` policy, and — for the direct, no-model A2A path — the `publicAgent` opt-in below. + ## Block extension iframes {#tool-callable} Extensions ([Alpine.js mini-apps in sandboxed iframes](/docs/extensions)) call actions through `appAction(name, params)`, running with the viewer's own permissions, secrets, and SQL scope. For sensitive operations, that's too much trust by default. Set `toolCallable: false` to make the extension bridge return a 403, while keeping the action callable from the UI, agent, CLI, MCP, and A2A: diff --git a/packages/core/docs/content/actions-defining.mdx b/packages/core/docs/content/actions-defining.mdx index 9765c312fd..be0085933a 100644 --- a/packages/core/docs/content/actions-defining.mdx +++ b/packages/core/docs/content/actions-defining.mdx @@ -115,6 +115,21 @@ The framework auto-discovers every file in `actions/` and mounts it on startup. description: "Set false to hide from every agent tool list. See Access & Authorization.", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "Defaults to agentTool. Set false to hide from external agents over MCP and A2A, or true to declare curated catalog membership (with agentTool: false, MCP-only). See Access & Authorization.", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "Set false to keep the action in the agent's first tool list, true to load it on demand through tool-search. See Keep the action surface small below.", + }, { name: "toolCallable", type: "boolean", @@ -261,6 +276,7 @@ Every action the agent can see takes up a slot in the model's tool list, and a l - Prefer **one CRUD-style `update` action** that takes a patch of optional fields, over many per-field actions like `update-name`, `update-order`, and `update-color`. The caller only sends the fields that changed. - Before adding a new read action for every query or filter, consider a more general option first: the [provider API trio](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog`, `provider-api-docs`, and `provider-api-request`) for provider data, or the dev `db-query` tool for app data. - Mark UI-only or programmatic actions [`agentTool: false`](/docs/actions-access-control#agent-tool). They stay callable from the frontend or over HTTP, without taking a slot in the model's tool list. +- Mark the handful of actions the agent reaches for constantly `deferLoading: false`. Those are the schemas sent on the first model request; everything else loads on demand through `tool-search` when the agent needs it. As soon as one action opts out this way, every unmarked action is deferred, so mark the whole starter set at once rather than one action at a time. This is about what the first turn costs, not about access: a deferred action is callable the moment `tool-search` returns it. It is the per-action form of the plugin's `initialToolNames` array, and an app can use either. - Delete or hide actions the UI no longer uses, instead of leaving them exposed to the model. The repo includes an advisory helper, `node scripts/audit-template-actions.mjs [template ...]` (alias `pnpm actions:audit`). It scans a template's `actions/` folder and flags actions the UI may no longer use, along with groups of per-field actions that could be combined into one. It always exits with code 0 and never fails CI, and its heuristics are conservative, so treat its output as a suggestion, not an error. diff --git a/packages/core/docs/content/locales/ar-SA/actions-access-control.mdx b/packages/core/docs/content/locales/ar-SA/actions-access-control.mdx index 6f86cbfa19..98f84912a7 100644 --- a/packages/core/docs/content/locales/ar-SA/actions-access-control.mdx +++ b/packages/core/docs/content/locales/ar-SA/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "الوصول والتخويل" -description: "من يمكنه استدعاء action: أعلام التعريض (agentTool، وtoolCallable)، والنطاق المستند إلى ctx، وaccessFilter/assertAccess، وحارس authorize." +description: "من يمكنه استدعاء action: أعلام التعريض (agentTool، وmcpTool، وtoolCallable)، والنطاق المستند إلى ctx، وaccessFilter/assertAccess، وحارس authorize." --- # الوصول والتخويل -تحدد خمسة إعدادات من يمكنه استدعاء action: الوكيل، أو الواجهة الأمامية، أو امتداد، أو مستدعي API خارجي، أو لا أحد من دون موافقة إنسان أولًا. لا تحتاج معظم actions أبدًا إلى لمس أي من هذه، لأن القيم الافتراضية تناسب بالفعل الاستخدام العادي لواجهة المستخدم والوكيل. +تحدد ستة إعدادات من يمكنه استدعاء action: الوكيل، أو الواجهة الأمامية، أو امتداد، أو مستدعي API خارجي، أو لا أحد من دون موافقة إنسان أولًا. لا تحتاج معظم actions أبدًا إلى لمس أي من هذه، لأن القيم الافتراضية تناسب بالفعل الاستخدام العادي لواجهة المستخدم والوكيل. ### أعلام التعريض {#exposure-flags} -تُضبط الأعلام الخمسة جميعًا افتراضيًا على القيمة المتساهلة، لذا لا تضبط أيًا منها إلا عندما تحتاج إلى تشديد سطح معيّن. الجدول أدناه ملخص سريع. تضيف الأقسام التي تليه التفصيل الوحيد الذي يحتاجه كل علم. +تُضبط الأعلام الستة جميعًا افتراضيًا على القيمة المتساهلة، ويأخذ `mcpTool` قيمته الافتراضية من `agentTool`، لذا لا تضبط أيًا منها إلا عندما تحتاج إلى تشديد سطح معيّن. الجدول أدناه ملخص سريع. تضيف الأقسام التي تليه التفصيل الوحيد الذي يحتاجه كل علم. | العلم | الافتراضي | القيمة التقييدية ← من لا يزال بإمكانه الاستدعاء | الاستخدام النموذجي | | --------------- | ------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `agentTool` | `true` | `false` ← واجهة المستخدم وHTTP وCLI فقط (مخفي عن النموذج وMCP وA2A) | actions خاصة بواجهة المستخدم فقط أو برمجية لا ينبغي أن تشغل فتحة أداة | +| `mcpTool` | `agentTool` | `false` ← وكيل تطبيقك أنت فقط (مخفي عن MCP وA2A) | actions تحتاج إلى شاشة أو جلسة من التطبيق على الطرف الآخر | | `toolCallable` | `true` | `false` ← كل شيء **باستثناء** جسر iframe المعزول الخاص بالامتداد (403) | تغييرات حساسة في الحساب أو المؤسسة (حذف حساب، تغيير عضوية) | | `publicAgent` | معطَّل (خاص) | `{ expose: true }` ← يضيف الـ action إلى أسطح MCP/A2A/OpenAPI **العامة** | أدوات قراءة أو استيعاب آمنة لا تتطلب مصادقة | | `needsApproval` | `false` | `true` ← **يتوقف** الوكيل مؤقتًا، ويجب أن يوافق إنسان على الاستدعاء المحدد | تأثيرات جانبية ذات عواقب (إرسال بريد إلكتروني، شحن بطاقة، حذف) | | `authorize` | لا شيء | دالة حارسة ← فقط المستدعون الذين تقبلهم، على **كل** سطح | عمليات مقيَّدة بدور (RBAC لكل تطبيق، عمليات خاصة بالمسؤول فقط) | -هذه الأعلام مستقلة، لذا فإن ضبط واحد منها لا يغيّر الآخرين. يتحكم `agentTool` بما يراه النموذج، راجع [الإخفاء عن النموذج](#agent-tool) أدناه. يتحكم `toolCallable` فقط بـ iframe الامتداد، راجع [حظر أطر الامتداد](#tool-callable) أدناه. يضيف `publicAgent` سطحًا عامًا بالاختيار، ولا يعني مسار ويب عام أبدًا تعريضًا عامًا للأداة، راجع [التعريض للوكلاء العامين](#public-agent) أدناه. يضبط `needsApproval` التنفيذ بعد إجراء الاستدعاء فعلًا، راجع [طلب موافقة الإنسان](#needs-approval) أدناه. يقرر `authorize` ما إذا كان يجوز لهذا المستدعي تشغيل الـ action أصلًا، على كل مسار إرسال، قبل الدخول إلى `run()`، راجع [تقييد المستدعين حسب الدور](#authorize) أدناه. +هذه الأعلام مستقلة، باستثناء واحد: `mcpTool` غير المصرَّح به يرث `agentTool`. يتحكم `agentTool` بما يراه النموذج، راجع [الإخفاء عن النموذج](#agent-tool) أدناه. يضيّق `mcpTool` ذلك ليقتصر على الوكلاء الخارجيين، راجع [الإخفاء عن الوكلاء الخارجيين](#mcp-tool) أدناه. يتحكم `toolCallable` فقط بـ iframe الامتداد، راجع [حظر أطر الامتداد](#tool-callable) أدناه. يضيف `publicAgent` سطحًا عامًا بالاختيار، ولا يعني مسار ويب عام أبدًا تعريضًا عامًا للأداة، راجع [التعريض للوكلاء العامين](#public-agent) أدناه. يضبط `needsApproval` التنفيذ بعد إجراء الاستدعاء فعلًا، راجع [طلب موافقة الإنسان](#needs-approval) أدناه. يقرر `authorize` ما إذا كان يجوز لهذا المستدعي تشغيل الـ action أصلًا، على كل مسار إرسال، قبل الدخول إلى `run()`، راجع [تقييد المستدعين حسب الدور](#authorize) أدناه. ## الإخفاء عن النموذج {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ استخدم هذا عندما تضيف action خاصًا بواجهة المستخدم فقط أو برمجيًا بحتًا، أو عندما تتوقف واجهة المستخدم عن استخدام action كان سيبقى مكشوفًا للنموذج لولا ذلك. +## الإخفاء عن الوكلاء الخارجيين {#mcp-tool} + +`agentTool` إما كل شيء أو لا شيء: الـ action إما أداة لكل وكيل، أو ليست أداة لأحد. يقسم `mcpTool` ذلك إلى نصفين، فتبقى الـ action أداة عادية لوكيل تطبيقك أنت، من دون أن تصل أبدًا إلى Claude أو Cursor أو تطبيق شقيق عبر MCP وA2A: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +استخدمه مع actions لا تعني نتيجتها شيئًا إلا مع واجهة تطبيقك، أو جلسة مفتوحة، أو التحديد الحالي أمام المستدعي. لا يستطيع `mcpTool` إلا تضييق `agentTool`، ولا يوسّعه أبدًا: تبقى الـ action التي تحمل `agentTool: false` مخفية في كل مكان. فهو يحكم سطح الأدوات الخارجي نفسه، لا ما يجوز لوكيلك أنت فعله وهو يجيب عن سؤال `ask_app` — فتلك الجولة جولة وكيلك، ويحتفظ فيها بقائمة أدواته كاملة. + +ومن دون تصريح، يتبع `mcpTool` قيمة `agentTool` بدلًا من `true` ثابتة، فإخفاء الـ action عن الوكيل يخفيها عن الوكلاء الخارجيين أيضًا، ويبقى العلم الواحد قرارًا واحدًا. + +وللقيمة المعاكسة وظيفة ثانية. يُقدَّم للوكلاء الخارجيين افتراضيًا فهرس صغير منتقى بدلًا من سجل actions كامل، و`mcpTool: true` هو الطريقة التي تعلن بها الـ action انتماءها إليه: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +هذا هو الفهرس نفسه الذي تمثله قائمة `mcp: { connectorCatalog: [...] }` في الملحق، لكنه معلن على الـ action بدلًا من مصفوفة منفصلة يجب إبقاؤها متوافقة مع كل إعادة تسمية. كلا الشكلين يعمل، ويمكن للتطبيق الانتقال action تلو الأخرى. ويبقى كل ما استُبعد قابلًا للاكتشاف عبر `tool-search`. + +أما الجمع المعاكس بين العلمين — `agentTool: false` مع `mcpTool: true` — فيجعل الـ action **خاصة بـ MCP فقط**: يحصل عليها الوكلاء الخارجيون، ولا يراها وكيل تطبيقك أنت أبدًا. استخدم ذلك لعمليات التصدير أو المزامنة أو التسليم التي تحتاجها أداة خارجية ولا سبب لوكيلك أن يستدعيها. + +الانتماء إلى الفهرس ليس إذنًا. لا يزال على المستدعي الخارجي اجتياز فحص نطاق OAuth وسياسة `externalAgents`، وكذلك — في مسار A2A المباشر بلا نموذج — موافقة `publicAgent` أدناه. + ## حظر أطر الامتداد iframe {#tool-callable} تستدعي الامتدادات ([تطبيقات Alpine.js مصغّرة في إطارات iframe معزولة](/docs/extensions)) actions من خلال `appAction(name, params)`، وتعمل بصلاحيات المُشاهد نفسه وأسراره ونطاق SQL الخاص به. بالنسبة إلى العمليات الحساسة، هذه ثقة زائدة عن الحد بشكل افتراضي. اضبط `toolCallable: false` لجعل جسر الامتداد يُرجع 403، مع إبقاء الـ action قابلًا للاستدعاء من واجهة المستخدم والوكيل وCLI وMCP وA2A: diff --git a/packages/core/docs/content/locales/ar-SA/actions-defining.mdx b/packages/core/docs/content/locales/ar-SA/actions-defining.mdx index 4472f168be..8aaf4ef9ec 100644 --- a/packages/core/docs/content/locales/ar-SA/actions-defining.mdx +++ b/packages/core/docs/content/locales/ar-SA/actions-defining.mdx @@ -114,6 +114,21 @@ description: "تشريح defineAction(): المخطط، وrun، وإعداد HTT description: "اضبطه على false لإخفائه عن كل قائمة أدوات الوكيل. راجع الوصول والتخويل.", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "يرث `agentTool`. اضبطه على false لإخفائها عن الوكلاء الخارجيين عبر MCP وA2A، أو على true لإعلان انتمائها إلى الفهرس المنتقى. راجع الوصول والتخويل.", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "اضبطه على false لإبقاء الـ action في أول قائمة أدوات للوكيل، أو على true ليُحمَّل عند الحاجة عبر tool-search. راجع «أبقِ سطح actions صغيرًا» أدناه.", + }, { name: "toolCallable", type: "boolean", @@ -259,6 +274,7 @@ POST هو الافتراضي، ويجعله `http: { method: "GET" }` GET. تس - فضّل **action واحد بأسلوب CRUD باسم `update`** يأخذ تصحيحًا (patch) من الحقول الاختيارية، بدلاً من actions عديدة لكل حقل مثل `update-name` و`update-order` و`update-color`. لا يرسل المستدعي إلا الحقول التي تغيّرت. - قبل إضافة action قراءة جديد لكل استعلام أو عامل تصفية، ضع في اعتبارك خيارًا أكثر عمومية أولاً: [ثلاثي provider API](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog` و`provider-api-docs` و`provider-api-request`) لبيانات الموفر، أو أداة `db-query` للتطوير لبيانات التطبيق. - علّم actions الخاصة بواجهة المستخدم فقط أو البرمجية بـ [`agentTool: false`](/docs/actions-access-control#agent-tool). تبقى قابلة للاستدعاء من الواجهة الأمامية أو عبر HTTP، دون أن تشغل فتحة في قائمة أدوات النموذج. +- ضع `deferLoading: false` على الحفنة القليلة من actions التي يلجأ إليها الوكيل باستمرار. هذه هي المخططات التي تُرسَل في أول طلب إلى النموذج؛ وكل ما عداها يُحمَّل عند الحاجة عبر `tool-search`. وبمجرد أن تخرج action واحدة بهذه الطريقة، تُؤجَّل كل action غير معلَّمة، لذا علّم مجموعة البداية كاملة دفعة واحدة لا action تلو الأخرى. المسألة هنا كلفة الدور الأول، لا الصلاحية: تبقى الـ action المؤجَّلة قابلة للاستدعاء فور أن يعيدها `tool-search`. وهذا هو الشكل الخاص بكل action من مصفوفة `initialToolNames` في الملحق، ويمكن للتطبيق استخدام أيٍّ من الشكلين. - احذف أو أخفِ actions التي لم تعد واجهة المستخدم تستخدمها، بدلاً من تركها مكشوفة للنموذج. يتضمن المستودع مساعدًا استشاريًا، `node scripts/audit-template-actions.mjs [template ...]` (بالاسم المستعار `pnpm actions:audit`). يفحص مجلد `actions/` الخاص بالقالب ويشير إلى actions قد لا تستخدمها واجهة المستخدم بعد الآن، إلى جانب مجموعات actions لكل حقل يمكن دمجها في واحد. يخرج دائمًا برمز 0 ولا يُفشل CI أبدًا، وقواعده الاستدلالية متحفظة، لذا تعامل مع مخرجاته على أنها اقتراح، لا خطأ. diff --git a/packages/core/docs/content/locales/de-DE/actions-access-control.mdx b/packages/core/docs/content/locales/de-DE/actions-access-control.mdx index 5aaa3db303..0db0c4d61b 100644 --- a/packages/core/docs/content/locales/de-DE/actions-access-control.mdx +++ b/packages/core/docs/content/locales/de-DE/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "Zugriff & Autorisierung" -description: "Wer eine Action aufrufen darf: Expositions-Flags (agentTool, toolCallable), ctx-basiertes Scoping, accessFilter/assertAccess und der authorize-Guard." +description: "Wer eine Action aufrufen darf: Expositions-Flags (agentTool, mcpTool, toolCallable), ctx-basiertes Scoping, accessFilter/assertAccess und der authorize-Guard." --- # Zugriff & Autorisierung -Fünf Einstellungen entscheiden, wer eine Action aufrufen darf: der Agent, das Frontend, eine Extension, ein externer API-Aufrufer oder niemand ohne vorherige Genehmigung durch einen Menschen. Die meisten Actions müssen keine davon je anfassen, da die Standardwerte bereits zum normalen UI-und-Agent-Gebrauch passen. +Sechs Einstellungen entscheiden, wer eine Action aufrufen darf: der Agent, das Frontend, eine Extension, ein externer API-Aufrufer oder niemand ohne vorherige Genehmigung durch einen Menschen. Die meisten Actions müssen keine davon je anfassen, da die Standardwerte bereits zum normalen UI-und-Agent-Gebrauch passen. ### Expositions-Flags {#exposure-flags} -Alle fünf Flags sind standardmäßig auf den freizügigsten Wert gesetzt, sodass Sie nur eines setzen, wenn Sie eine bestimmte Oberfläche einschränken müssen. Die Tabelle unten ist eine kurze Zusammenfassung. Die Abschnitte danach fügen das eine Detail hinzu, das jedes Flag braucht. +Alle sechs sind standardmäßig auf den freizügigsten Wert gesetzt – `mcpTool`, indem es `agentTool` erbt –, sodass Sie nur eines setzen, wenn Sie eine bestimmte Oberfläche einschränken müssen. Die Tabelle unten ist eine kurze Zusammenfassung. Die Abschnitte danach fügen das eine Detail hinzu, das jedes Flag braucht. | Flag | Standard | Restriktiver Wert → wer noch aufrufen kann | Typische Verwendung | | --------------- | ------------ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `agentTool` | `true` | `false` → nur UI, HTTP und CLI (vor dem Modell, MCP und A2A verborgen) | Reine UI- oder programmatische Actions, die keinen Tool-Platz belegen sollen | +| `mcpTool` | `agentTool` | `false` → nur der Agent Ihrer eigenen App (vor MCP und A2A verborgen) | Actions, die auf der Gegenseite einen App-Screen oder eine Sitzung brauchen | | `toolCallable` | `true` | `false` → alles **außer** der sandboxed Extension-Iframe-Bridge (403) | Sensible Konto- oder Org-Änderungen (Konto löschen, Mitgliedschaft ändern) | | `publicAgent` | aus (privat) | `{ expose: true }` → fügt die Action zu **öffentlichen** MCP-/A2A-/OpenAPI-Oberflächen hinzu | Sichere Lese- oder Ingest-Tools, die keine Authentifizierung erfordern | | `needsApproval` | `false` | `true` → der Agent **pausiert**, und ein Mensch muss den konkreten Aufruf genehmigen | Folgenreiche Nebenwirkungen (E-Mail senden, Karte belasten, löschen) | | `authorize` | keine | eine Guard-Funktion → nur Aufrufer, die sie akzeptiert, auf **jeder** Oberfläche | Auf eine Rolle beschränkte Operationen (App-eigenes RBAC, Admin-only-Vorgänge) | -Diese Flags sind unabhängig voneinander, sodass das Setzen eines Flags die anderen nicht ändert. `agentTool` steuert, was das Modell sieht, siehe [Vor dem Modell verbergen](#agent-tool) unten. `toolCallable` steuert nur das Extension-Iframe, siehe [Extension-Iframes blockieren](#tool-callable) unten. `publicAgent` fügt eine Opt-in-öffentliche Oberfläche hinzu, und eine öffentliche Web-Route impliziert nie eine öffentliche Tool-Exposition, siehe [Für öffentliche Agenten freigeben](#public-agent) unten. `needsApproval` steuert die Ausführung, nachdem der Aufruf bereits erfolgt ist, siehe [Menschliche Genehmigung erfordern](#needs-approval) unten. `authorize` entscheidet, ob dieser Aufrufer die Action überhaupt ausführen darf, auf jedem Dispatch-Pfad, bevor `run()` betreten wird, siehe [Aufrufer nach Rolle einschränken](#authorize) unten. +Diese Flags sind unabhängig voneinander, mit einer Ausnahme: Ein nicht deklariertes `mcpTool` erbt `agentTool`. `agentTool` steuert, was das Modell sieht, siehe [Vor dem Modell verbergen](#agent-tool) unten. `mcpTool` schränkt das auf externe Agenten ein, siehe [Vor externen Agenten verbergen](#mcp-tool) unten. `toolCallable` steuert nur das Extension-Iframe, siehe [Extension-Iframes blockieren](#tool-callable) unten. `publicAgent` fügt eine Opt-in-öffentliche Oberfläche hinzu, und eine öffentliche Web-Route impliziert nie eine öffentliche Tool-Exposition, siehe [Für öffentliche Agenten freigeben](#public-agent) unten. `needsApproval` steuert die Ausführung, nachdem der Aufruf bereits erfolgt ist, siehe [Menschliche Genehmigung erfordern](#needs-approval) unten. `authorize` entscheidet, ob dieser Aufrufer die Action überhaupt ausführen darf, auf jedem Dispatch-Pfad, bevor `run()` betreten wird, siehe [Aufrufer nach Rolle einschränken](#authorize) unten. ## Vor dem Modell verbergen {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ Verwenden Sie dies, wenn Sie eine reine UI- oder rein programmatische Action hinzufügen, oder wenn die UI aufhört, eine Action zu verwenden, die andernfalls dem Modell ausgesetzt bliebe. +## Vor externen Agenten verbergen {#mcp-tool} + +`agentTool` ist ganz oder gar nicht: Eine Action ist ein Tool für jeden Agenten oder für keinen. `mcpTool` teilt das in zwei Hälften, sodass eine Action ein normales Tool für den Agenten Ihrer eigenen App bleiben kann, ohne jemals über MCP und A2A zu Claude, Cursor oder eine Schwester-App zu gelangen: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +Verwenden Sie es für Actions, deren Ergebnis nur zusammen mit der UI Ihrer App, einer offenen Sitzung oder der aktuellen Auswahl vor dem Aufrufer eine Bedeutung hat. `mcpTool` kann `agentTool` nur einschränken, nie erweitern: Eine Action mit `agentTool: false` bleibt überall verborgen. Es steuert die externe Tool-Oberfläche selbst, nicht das, was Ihr eigener Agent tun darf, während er eine `ask_app`-Frage beantwortet – dieser Lauf gehört Ihrem Agenten, und er behält seine komplette Tool-Liste. + +Ohne Deklaration folgt `mcpTool` dem Wert von `agentTool`, statt auf ein pauschales `true` zu fallen: Wer eine Action vor dem Agenten verbirgt, verbirgt sie damit auch vor externen Agenten, und ein Flag bleibt eine Entscheidung. + +Der entgegengesetzte Wert hat eine zweite Aufgabe. Externen Agenten wird standardmäßig ein kleiner, kuratierter Katalog serviert statt Ihrer gesamten Action-Registry, und mit `mcpTool: true` erklärt sich eine Action zu einem Teil davon: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +Das ist derselbe Katalog wie die Liste `mcp: { connectorCatalog: [...] }` des Plugins, nur an der Action deklariert statt in einem separaten Array, das bei jeder Umbenennung nachgezogen werden muss. Beide Formen funktionieren, und eine App kann Action für Action umstellen. Alles, was ausgelassen wird, bleibt über `tool-search` auffindbar. + +Die umgekehrte Kombination – `agentTool: false` mit `mcpTool: true` – macht eine Action **MCP-only**: Externe Agenten bekommen sie, der Agent Ihrer eigenen App sieht sie nie. Nutzen Sie das für den Export, die Synchronisierung oder die Übergabe, die ein externes Tool braucht und Ihr eigener Agent nie aufrufen muss. + +Mitgliedschaft ist keine Berechtigung. Ein externer Aufrufer muss weiterhin die OAuth-Scope-Prüfung, die `externalAgents`-Policy und – für den direkten A2A-Pfad ohne Modell – das `publicAgent`-Opt-in weiter unten passieren. + ## Extension-Iframes blockieren {#tool-callable} Extensions ([Alpine.js-Mini-Apps in sandboxed Iframes](/docs/extensions)) rufen Actions über `appAction(name, params)` auf und laufen dabei mit den eigenen Berechtigungen, Secrets und dem SQL-Scope des Betrachters. Für sensible Operationen ist das standardmäßig zu viel Vertrauen. Setzen Sie `toolCallable: false`, damit die Extension-Bridge einen 403 zurückgibt, während die Action von UI, Agent, CLI, MCP und A2A weiterhin aufrufbar bleibt: diff --git a/packages/core/docs/content/locales/de-DE/actions-defining.mdx b/packages/core/docs/content/locales/de-DE/actions-defining.mdx index d141f05f1e..5019625474 100644 --- a/packages/core/docs/content/locales/de-DE/actions-defining.mdx +++ b/packages/core/docs/content/locales/de-DE/actions-defining.mdx @@ -115,6 +115,21 @@ Das Framework entdeckt jede Datei in `actions/` automatisch und bindet sie beim description: "Auf false setzen, um sie aus jeder Agent-Tool-Liste auszublenden. Siehe Zugriff & Autorisierung.", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "Erbt `agentTool`. `false` verbirgt sie vor externen Agenten über MCP und A2A, `true` deklariert die Zugehörigkeit zum kuratierten Katalog. Siehe Zugriff & Autorisierung.", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "`false` behält die Action in der ersten Tool-Liste des Agenten, `true` lädt sie bei Bedarf über `tool-search`. Siehe „Die Action-Oberfläche klein halten“ unten.", + }, { name: "toolCallable", type: "boolean", @@ -261,6 +276,7 @@ Jede Action, die der Agent sehen kann, belegt einen Platz in der Tool-Liste des - Bevorzugen Sie **eine CRUD-artige `update`-Action**, die ein Patch aus optionalen Feldern annimmt, gegenüber vielen Pro-Feld-Actions wie `update-name`, `update-order` und `update-color`. Der Aufrufer sendet nur die Felder, die sich geändert haben. - Bevor Sie für jede Abfrage oder jeden Filter eine neue Lese-Action hinzufügen, ziehen Sie zuerst eine allgemeinere Option in Betracht: das [Provider-API-Trio](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog`, `provider-api-docs` und `provider-api-request`) für Provider-Daten, oder das Dev-Tool `db-query` für App-Daten. - Markieren Sie reine UI- oder programmatische Actions als [`agentTool: false`](/docs/actions-access-control#agent-tool). Sie bleiben vom Frontend oder über HTTP aufrufbar, ohne einen Platz in der Tool-Liste des Modells zu belegen. +- Markieren Sie die wenigen Actions, zu denen der Agent ständig greift, mit `deferLoading: false`. Genau deren Schemas werden mit der ersten Modellanfrage gesendet; alles andere lädt bei Bedarf über `tool-search` nach. Sobald sich eine einzige Action so abmeldet, wird jede nicht markierte Action zurückgestellt, markieren Sie also den ganzen Startsatz auf einmal statt eine Action nach der anderen. Es geht darum, was der erste Zug kostet, nicht um Zugriff: Eine zurückgestellte Action ist aufrufbar, sobald `tool-search` sie zurückgibt. Das ist die Action-eigene Form des Plugin-Arrays `initialToolNames`, und eine App kann beides nutzen. - Löschen oder verbergen Sie Actions, die die UI nicht mehr verwendet, statt sie dem Modell weiterhin auszusetzen. Das Repo enthält einen beratenden Helfer, `node scripts/audit-template-actions.mjs [template ...]` (Alias `pnpm actions:audit`). Er durchsucht den `actions/`-Ordner eines Templates und markiert Actions, die die UI möglicherweise nicht mehr verwendet, zusammen mit Gruppen von Pro-Feld-Actions, die zu einer zusammengefasst werden könnten. Er endet immer mit Code 0 und lässt CI nie fehlschlagen, und seine Heuristiken sind konservativ, behandeln Sie seine Ausgabe also als Vorschlag, nicht als Fehler. diff --git a/packages/core/docs/content/locales/es-ES/actions-access-control.mdx b/packages/core/docs/content/locales/es-ES/actions-access-control.mdx index ecd24bd24f..9a95fcc116 100644 --- a/packages/core/docs/content/locales/es-ES/actions-access-control.mdx +++ b/packages/core/docs/content/locales/es-ES/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "Acceso y autorización" -description: "Quién puede llamar a una acción: las opciones de exposición (agentTool, toolCallable), el alcance basado en ctx, accessFilter/assertAccess y la guarda authorize." +description: "Quién puede llamar a una acción: las opciones de exposición (agentTool, mcpTool, toolCallable), el alcance basado en ctx, accessFilter/assertAccess y la guarda authorize." --- # Acceso y autorización -Cinco opciones deciden quién puede llamar a una acción: el agente, el frontend, una extensión, un invocador de API externo, o nadie sin la aprobación previa de un humano. La mayoría de las acciones nunca necesitan tocar ninguna de ellas, ya que los valores por defecto ya se ajustan al uso normal de UI y agente. +Seis opciones deciden quién puede llamar a una acción: el agente, el frontend, una extensión, un invocador de API externo, o nadie sin la aprobación previa de un humano. La mayoría de las acciones nunca necesitan tocar ninguna de ellas, ya que los valores por defecto ya se ajustan al uso normal de UI y agente. ### Opciones de exposición {#exposure-flags} -Las cinco opciones tienen por defecto el valor más permisivo, así que solo configuras una cuando necesitas restringir una superficie específica. La tabla de abajo es un resumen rápido. Las secciones posteriores añaden el detalle concreto que necesita cada opción. +Las seis tienen por defecto el valor más permisivo — `mcpTool` heredando `agentTool` —, así que solo configuras una cuando necesitas restringir una superficie específica. La tabla de abajo es un resumen rápido. Las secciones posteriores añaden el detalle concreto que necesita cada opción. | Opción | Predeterminado | Valor restrictivo → quién puede seguir llamando | Uso típico | | --------------- | --------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `agentTool` | `true` | `false` → solo UI, HTTP y CLI (oculta para el modelo, MCP y A2A) | Acciones solo de UI o programáticas que no deberían ocupar una plaza de herramienta | +| `mcpTool` | `agentTool` | `false` → solo el agente de tu propia app (oculta para MCP y A2A) | Acciones que necesitan una pantalla o una sesión de la app al otro lado | | `toolCallable` | `true` | `false` → todo **excepto** el puente del iframe aislado de extensiones (403) | Cambios sensibles de cuenta u organización (eliminar cuenta, cambiar membresía) | | `publicAgent` | desactivado (privado) | `{ expose: true }` → añade la acción a las superficies **públicas** de MCP/A2A/OpenAPI | Herramientas seguras de lectura o ingesta que no requieren autenticación | | `needsApproval` | `false` | `true` → el agente **se pausa**, y un humano debe aprobar la llamada específica | Efectos secundarios de consecuencia (enviar un correo, cobrar una tarjeta, eliminar) | | `authorize` | ninguno | una función de guarda → solo los invocadores que acepta, en **todas** las superficies | Operaciones restringidas a un rol (RBAC por app, operaciones solo para administradores) | -Estas opciones son independientes, así que configurar una no cambia las demás. `agentTool` controla qué ve el modelo, consulta [Ocultar del modelo](#agent-tool) más abajo. `toolCallable` controla solo el iframe de extensiones, consulta [Bloquear iframes de extensión](#tool-callable) más abajo. `publicAgent` añade una superficie pública opcional, y una ruta web pública nunca implica exposición pública de la herramienta, consulta [Exponer a agentes públicos](#public-agent) más abajo. `needsApproval` controla la ejecución después de que la llamada ya se ha hecho, consulta [Requerir aprobación humana](#needs-approval) más abajo. `authorize` decide si este invocador puede ejecutar la acción, en cada ruta de despacho, antes de entrar en `run()`, consulta [Restringir invocadores por rol](#authorize) más abajo. +Estas opciones son independientes, con una excepción: un `mcpTool` sin declarar hereda `agentTool`. `agentTool` controla qué ve el modelo, consulta [Ocultar del modelo](#agent-tool) más abajo. `mcpTool` restringe eso solo a los agentes externos, consulta [Ocultar de los agentes externos](#mcp-tool) más abajo. `toolCallable` controla solo el iframe de extensiones, consulta [Bloquear iframes de extensión](#tool-callable) más abajo. `publicAgent` añade una superficie pública opcional, y una ruta web pública nunca implica exposición pública de la herramienta, consulta [Exponer a agentes públicos](#public-agent) más abajo. `needsApproval` controla la ejecución después de que la llamada ya se ha hecho, consulta [Requerir aprobación humana](#needs-approval) más abajo. `authorize` decide si este invocador puede ejecutar la acción, en cada ruta de despacho, antes de entrar en `run()`, consulta [Restringir invocadores por rol](#authorize) más abajo. ## Ocultar del modelo {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ Usa esto cuando añadas una acción solo de UI o puramente programática, o cuando la UI deje de usar una acción que de otro modo seguiría expuesta al modelo. +## Ocultar de los agentes externos {#mcp-tool} + +`agentTool` es todo o nada: una acción es una herramienta para todos los agentes, o para ninguno. `mcpTool` divide eso en dos, de modo que una acción puede seguir siendo una herramienta normal para el agente de tu propia app sin llegar nunca a Claude, Cursor o una app hermana a través de MCP y A2A: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +Úsalo para acciones cuyo resultado solo significa algo junto con la UI de tu app, una sesión abierta o la selección actual delante del invocador. `mcpTool` solo puede restringir `agentTool`, nunca ampliarlo: una acción con `agentTool: false` permanece oculta en todas partes. Gobierna la superficie de herramientas externa en sí, no lo que tu propio agente puede hacer mientras responde una pregunta de `ask_app`: esa ejecución es de tu agente, y conserva toda su lista de herramientas. + +Si no la declaras, `mcpTool` sigue a `agentTool` en lugar de tomar un `true` fijo, así que ocultar una acción al agente la oculta también a los agentes externos y una opción sigue siendo una sola decisión. + +El valor opuesto tiene un segundo cometido. A los agentes externos se les sirve por defecto un catálogo pequeño y curado en lugar de todo tu registro de acciones, y `mcpTool: true` es la forma en que una acción se declara parte de él: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +Es el mismo catálogo que la lista `mcp: { connectorCatalog: [...] }` del plugin, declarado en la acción en lugar de en un array aparte que hay que mantener sincronizado a medida que se renombran las acciones. Ambas formas funcionan, y una app puede migrar una acción a la vez. Todo lo que queda fuera sigue siendo localizable mediante `tool-search`. + +Combinar las dos opciones al revés — `agentTool: false` con `mcpTool: true` — hace que una acción sea **solo de MCP**: los agentes externos la reciben y el agente de tu propia app nunca la ve. Úsalo para la exportación, la sincronización o el traspaso que necesita una herramienta externa y que tu propio agente no tiene motivo para llamar. + +Pertenecer al catálogo no es permiso. Un invocador externo todavía tiene que pasar la comprobación de scope de OAuth, la política `externalAgents` y, para la ruta A2A directa sin modelo, la aceptación explícita de `publicAgent` de más abajo. + ## Bloquear iframes de extensión {#tool-callable} Las extensiones ([mini-apps de Alpine.js en iframes aislados](/docs/extensions)) llaman a las acciones mediante `appAction(name, params)`, ejecutándose con los permisos, secretos y alcance SQL propios del visor. Para operaciones sensibles, eso es demasiada confianza por defecto. Configura `toolCallable: false` para que el puente de la extensión devuelva un 403, mientras la acción sigue siendo invocable desde la UI, el agente, la CLI, MCP y A2A: diff --git a/packages/core/docs/content/locales/es-ES/actions-defining.mdx b/packages/core/docs/content/locales/es-ES/actions-defining.mdx index bc1a951da3..a042cba3ac 100644 --- a/packages/core/docs/content/locales/es-ES/actions-defining.mdx +++ b/packages/core/docs/content/locales/es-ES/actions-defining.mdx @@ -115,6 +115,21 @@ El framework descubre automáticamente cada archivo en `actions/` y lo monta al description: "Ponlo en false para ocultarla de todas las listas de herramientas del agente. Consulta Acceso y autorización.", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "Hereda `agentTool`. Ponlo en false para ocultarla de los agentes externos por MCP y A2A, o en true para declarar su pertenencia al catálogo curado. Consulta Acceso y autorización.", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "Ponlo en false para mantener la acción en la primera lista de herramientas del agente, o en true para cargarla bajo demanda mediante tool-search. Consulta Mantén pequeña la superficie de acciones más abajo.", + }, { name: "toolCallable", type: "boolean", @@ -261,6 +276,7 @@ Cada acción que el agente puede ver ocupa una plaza en la lista de herramientas - Prefiere **una única acción `update` de estilo CRUD** que reciba un parche de campos opcionales, en lugar de muchas acciones por campo como `update-name`, `update-order` y `update-color`. El invocador solo envía los campos que cambiaron. - Antes de añadir una nueva acción de lectura para cada consulta o filtro, considera primero una opción más general: el [trío de API de proveedor](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog`, `provider-api-docs` y `provider-api-request`) para datos de proveedores, o la herramienta de desarrollo `db-query` para datos de la app. - Marca las acciones solo de UI o programáticas como [`agentTool: false`](/docs/actions-access-control#agent-tool). Siguen siendo invocables desde el frontend o por HTTP, sin ocupar una plaza en la lista de herramientas del modelo. +- Marca con `deferLoading: false` el puñado de acciones a las que el agente recurre constantemente. Esos son los esquemas que se envían en la primera petición al modelo; todo lo demás se carga bajo demanda mediante `tool-search` cuando el agente lo necesita. En cuanto una acción se excluye así, todas las no marcadas quedan diferidas, así que marca todo el conjunto inicial de una vez en lugar de una acción cada vez. Esto trata de lo que cuesta el primer turno, no del acceso: una acción diferida es invocable en cuanto `tool-search` la devuelve. Es la forma por acción del array `initialToolNames` del plugin, y una app puede usar cualquiera de las dos. - Elimina u oculta las acciones que la UI ya no usa, en lugar de dejarlas expuestas al modelo. El repo incluye un ayudante consultivo, `node scripts/audit-template-actions.mjs [template ...]` (alias `pnpm actions:audit`). Escanea la carpeta `actions/` de una plantilla y señala acciones que la UI puede haber dejado de usar, junto con grupos de acciones por campo que podrían combinarse en una sola. Siempre termina con código 0 y nunca hace fallar el CI, y sus heurísticas son conservadoras, así que trata su salida como una sugerencia, no como un error. diff --git a/packages/core/docs/content/locales/fr-FR/actions-access-control.mdx b/packages/core/docs/content/locales/fr-FR/actions-access-control.mdx index c15ccda78a..aa0154aff4 100644 --- a/packages/core/docs/content/locales/fr-FR/actions-access-control.mdx +++ b/packages/core/docs/content/locales/fr-FR/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "Accès et autorisation" -description: "Qui peut appeler une action : flags d’exposition (agentTool, toolCallable), portée basée sur ctx, accessFilter/assertAccess, et le garde authorize." +description: "Qui peut appeler une action : flags d’exposition (agentTool, mcpTool, toolCallable), portée basée sur ctx, accessFilter/assertAccess, et le garde authorize." --- # Accès et autorisation -Cinq paramètres décident qui peut appeler une action : l’agent, le frontend, une extension, un appelant API externe, ou personne sans l’approbation préalable d’un humain. La plupart des actions n’ont jamais besoin de toucher à l’un d’eux, puisque les valeurs par défaut conviennent déjà à un usage UI-et-agent normal. +Six paramètres décident qui peut appeler une action : l’agent, le frontend, une extension, un appelant API externe, ou personne sans l’approbation préalable d’un humain. La plupart des actions n’ont jamais besoin de toucher à l’un d’eux, puisque les valeurs par défaut conviennent déjà à un usage UI-et-agent normal. ### Flags d’exposition {#exposure-flags} -Les cinq flags valent par défaut la valeur permissive, donc vous n’en définissez un que lorsque vous devez restreindre une surface spécifique. Le tableau ci-dessous est un résumé rapide. Les sections qui suivent ajoutent le détail dont chaque flag a besoin. +Les six valent par défaut la valeur permissive — `mcpTool` en héritant d’`agentTool` —, donc vous n’en définissez un que lorsque vous devez restreindre une surface spécifique. Le tableau ci-dessous est un résumé rapide. Les sections qui suivent ajoutent le détail dont chaque flag a besoin. | Flag | Défaut | Valeur restrictive → qui peut encore appeler | Usage typique | | --------------- | ----------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `agentTool` | `true` | `false` → UI, HTTP, et CLI seulement (masqué au modèle, à MCP, et à A2A) | Actions réservées à l’UI ou programmatiques qui ne doivent pas prendre de place dans les outils | +| `mcpTool` | `agentTool` | `false` → l’agent de votre propre application seulement (masqué à MCP et A2A) | Actions qui exigent un écran ou une session de l’application en face | | `toolCallable` | `true` | `false` → tout **sauf** le pont iframe des extensions en bac à sable (403) | Changements sensibles de compte ou d’organisation (suppression de compte, changement d’adhésion) | | `publicAgent` | désactivé (privé) | `{ expose: true }` → ajoute l’action aux surfaces **publiques** MCP/A2A/OpenAPI | Outils de lecture ou d’ingestion sûrs qui n’exigent pas d’authentification | | `needsApproval` | `false` | `true` → l’agent **se met en pause**, et un humain doit approuver l’appel précis | Effets secondaires conséquents (envoyer un email, débiter une carte, supprimer) | | `authorize` | aucun | une fonction de garde → seuls les appelants qu’elle accepte, sur **chaque** surface | Opérations restreintes à un rôle (RBAC par application, opérations réservées aux admins) | -Ces flags sont indépendants, donc en définir un ne change pas les autres. `agentTool` contrôle ce que le modèle voit, voir [Masquer au modèle](#agent-tool) ci-dessous. `toolCallable` ne contrôle que l’iframe d’extension, voir [Bloquer les iframes d’extension](#tool-callable) ci-dessous. `publicAgent` ajoute une surface publique optionnelle, et une route web publique n’implique jamais une exposition d’outil publique, voir [Exposer aux agents publics](#public-agent) ci-dessous. `needsApproval` restreint l’exécution après que l’appel a déjà été fait, voir [Exiger une approbation humaine](#needs-approval) ci-dessous. `authorize` décide si cet appelant peut exécuter l’action, tout court, sur chaque chemin de dispatch, avant que `run()` ne soit entré, voir [Restreindre les appelants par rôle](#authorize) ci-dessous. +Ces flags sont indépendants, à une exception près : un `mcpTool` non déclaré hérite d’`agentTool`. `agentTool` contrôle ce que le modèle voit, voir [Masquer au modèle](#agent-tool) ci-dessous. `mcpTool` restreint cela aux seuls agents externes, voir [Masquer aux agents externes](#mcp-tool) ci-dessous. `toolCallable` ne contrôle que l’iframe d’extension, voir [Bloquer les iframes d’extension](#tool-callable) ci-dessous. `publicAgent` ajoute une surface publique optionnelle, et une route web publique n’implique jamais une exposition d’outil publique, voir [Exposer aux agents publics](#public-agent) ci-dessous. `needsApproval` restreint l’exécution après que l’appel a déjà été fait, voir [Exiger une approbation humaine](#needs-approval) ci-dessous. `authorize` décide si cet appelant peut exécuter l’action, tout court, sur chaque chemin de dispatch, avant que `run()` ne soit entré, voir [Restreindre les appelants par rôle](#authorize) ci-dessous. ## Masquer au modèle {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ Utilisez ceci quand vous ajoutez une action réservée à l’UI ou purement programmatique, ou quand l’UI cesse d’utiliser une action qui, sinon, resterait exposée au modèle. +## Masquer aux agents externes {#mcp-tool} + +`agentTool` fonctionne en tout ou rien : une action est un outil pour tous les agents, ou pour aucun. `mcpTool` scinde cela en deux, si bien qu’une action peut rester un outil normal pour l’agent de votre propre application sans jamais parvenir à Claude, à Cursor ou à une application sœur via MCP et A2A : + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +Utilisez-le pour les actions dont le résultat n’a de sens qu’avec l’UI de votre application, une session ouverte ou la sélection en cours devant l’appelant. `mcpTool` ne peut que restreindre `agentTool`, jamais l’élargir : une action avec `agentTool: false` reste masquée partout. Il régit la surface d’outils externe elle-même, pas ce que votre propre agent peut faire en répondant à une question `ask_app` : cette exécution appartient à votre agent, qui conserve toute sa liste d’outils. + +Non déclaré, `mcpTool` suit `agentTool` plutôt que de valoir un `true` fixe : masquer une action à l’agent la masque donc aussi aux agents externes, et un flag reste une seule décision. + +La valeur opposée a un second rôle. Les agents externes reçoivent par défaut un petit catalogue trié plutôt que tout votre registre d’actions, et `mcpTool: true` est la façon dont une action se déclare membre de ce catalogue : + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +C’est le même catalogue que la liste `mcp: { connectorCatalog: [...] }` du plugin, déclaré sur l’action au lieu d’un tableau séparé qu’il faut tenir à jour au fil des renommages. Les deux formes fonctionnent, et une application peut migrer action par action. Tout ce qui reste en dehors demeure trouvable via `tool-search`. + +Associer les deux flags dans l’autre sens — `agentTool: false` avec `mcpTool: true` — rend une action **exclusive à MCP** : les agents externes l’obtiennent, l’agent de votre propre application ne la voit jamais. Utilisez-le pour l’export, la synchronisation ou le passage de relais dont un outil externe a besoin et que votre agent n’a aucune raison d’appeler. + +Être dans le catalogue n’est pas une permission. Un appelant externe doit toujours passer la vérification de portée OAuth, la politique `externalAgents` et, pour le chemin A2A direct sans modèle, l’opt-in `publicAgent` ci-dessous. + ## Bloquer les iframes d’extension {#tool-callable} Les extensions ([mini-applications Alpine.js dans des iframes en bac à sable](/docs/extensions)) appellent les actions via `appAction(name, params)`, en s’exécutant avec les permissions, secrets, et la portée SQL du visiteur lui-même. Pour les opérations sensibles, c’est trop de confiance par défaut. Définissez `toolCallable: false` pour que le pont d’extension renvoie un 403, tout en gardant l’action appelable depuis l’UI, l’agent, la CLI, MCP, et A2A : diff --git a/packages/core/docs/content/locales/fr-FR/actions-defining.mdx b/packages/core/docs/content/locales/fr-FR/actions-defining.mdx index 5b04fef19c..3f6f5a0fbe 100644 --- a/packages/core/docs/content/locales/fr-FR/actions-defining.mdx +++ b/packages/core/docs/content/locales/fr-FR/actions-defining.mdx @@ -115,6 +115,21 @@ Le framework découvre automatiquement chaque fichier de `actions/` et le monte description: "Passez à false pour la masquer de toute liste d’outils de l’agent. Voir Accès et autorisation.", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "Hérite d’`agentTool`. Mettez false pour la masquer aux agents externes via MCP et A2A, ou true pour déclarer son appartenance au catalogue trié. Voir Accès et autorisation.", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "Mettez false pour garder l’action dans la première liste d’outils de l’agent, true pour la charger à la demande via tool-search. Voir « Gardez la surface d’actions petite » ci-dessous.", + }, { name: "toolCallable", type: "boolean", @@ -261,6 +276,7 @@ Chaque action visible par l’agent prend une place dans la liste d’outils du - Préférez **une seule action `update` de style CRUD** qui prend un patch de champs optionnels, plutôt que de nombreuses actions par champ comme `update-name`, `update-order`, et `update-color`. L’appelant n’envoie que les champs qui ont changé. - Avant d’ajouter une nouvelle action de lecture pour chaque requête ou filtre, envisagez d’abord une option plus générale : le [trio d’API fournisseur](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog`, `provider-api-docs`, et `provider-api-request`) pour les données fournisseur, ou l’outil dev `db-query` pour les données d’application. - Marquez les actions réservées à l’UI ou programmatiques [`agentTool: false`](/docs/actions-access-control#agent-tool). Elles restent appelables depuis le frontend ou via HTTP, sans prendre de place dans la liste d’outils du modèle. +- Marquez `deferLoading: false` la poignée d’actions que l’agent sollicite en permanence. Ce sont ces schémas qui sont envoyés à la première requête du modèle ; tout le reste se charge à la demande via `tool-search` quand l’agent en a besoin. Dès qu’une action s’exclut ainsi, toutes les actions non marquées sont différées : marquez donc tout l’ensemble de départ d’un coup plutôt qu’une action à la fois. Il s’agit du coût du premier tour, pas d’un contrôle d’accès : une action différée est appelable dès que `tool-search` la renvoie. C’est la forme par action du tableau `initialToolNames` du plugin, et une application peut utiliser l’une ou l’autre. - Supprimez ou masquez les actions que l’UI n’utilise plus, plutôt que de les laisser exposées au modèle. Le dépôt inclut un utilitaire consultatif, `node scripts/audit-template-actions.mjs [template ...]` (alias `pnpm actions:audit`). Il scanne le dossier `actions/` d’un template et signale les actions que l’UI n’utilise peut-être plus, ainsi que des groupes d’actions par champ qui pourraient être combinées en une seule. Il sort toujours avec le code 0 et ne fait jamais échouer la CI, et ses heuristiques sont prudentes, donc traitez sa sortie comme une suggestion, pas comme une erreur. diff --git a/packages/core/docs/content/locales/hi-IN/actions-access-control.mdx b/packages/core/docs/content/locales/hi-IN/actions-access-control.mdx index 1c21aa19a7..7e808e275f 100644 --- a/packages/core/docs/content/locales/hi-IN/actions-access-control.mdx +++ b/packages/core/docs/content/locales/hi-IN/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "Access और Authorization" -description: "कौन किसी action को कॉल कर सकता है: exposure flags (agentTool, toolCallable), ctx-आधारित scoping, accessFilter/assertAccess, और authorize गार्ड।" +description: "कौन किसी action को कॉल कर सकता है: exposure flags (agentTool, mcpTool, toolCallable), ctx-आधारित scoping, accessFilter/assertAccess, और authorize गार्ड।" --- # Access और Authorization -पांच सेटिंग्स तय करती हैं कि किसी action को कौन कॉल कर सकता है: एजेंट, फ्रंटएंड, कोई extension, कोई बाहरी API कॉलर, या पहले किसी मानव के अनुमोदन के बिना कोई नहीं। अधिकतर actions को इनमें से किसी को छूने की कभी ज़रूरत नहीं पड़ती, क्योंकि defaults पहले से सामान्य UI-और-एजेंट उपयोग के अनुकूल हैं। +छह सेटिंग्स तय करती हैं कि किसी action को कौन कॉल कर सकता है: एजेंट, फ्रंटएंड, कोई extension, कोई बाहरी API कॉलर, या पहले किसी मानव के अनुमोदन के बिना कोई नहीं। अधिकतर actions को इनमें से किसी को छूने की कभी ज़रूरत नहीं पड़ती, क्योंकि defaults पहले से सामान्य UI-और-एजेंट उपयोग के अनुकूल हैं। ### Exposure flags {#exposure-flags} -सभी पांच flags डिफ़ॉल्ट रूप से permissive वैल्यू पर होते हैं, इसलिए आप किसी एक को तभी सेट करते हैं जब आपको किसी विशिष्ट सतह को टाइट करने की आवश्यकता हो। नीचे दी गई तालिका एक त्वरित सारांश है। इसके बाद के सेक्शन हर flag की एक ज़रूरी जानकारी जोड़ते हैं। +सभी छह flags डिफ़ॉल्ट रूप से permissive वैल्यू पर होते हैं — `mcpTool` `agentTool` से विरासत में लेकर — इसलिए आप किसी एक को तभी सेट करते हैं जब आपको किसी विशिष्ट सतह को टाइट करने की आवश्यकता हो। नीचे दी गई तालिका एक त्वरित सारांश है। इसके बाद के सेक्शन हर flag की एक ज़रूरी जानकारी जोड़ते हैं। | Flag | डिफ़ॉल्ट | प्रतिबंधात्मक वैल्यू → कौन अभी भी कॉल कर सकता है | सामान्य उपयोग | | --------------- | ------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | | `agentTool` | `true` | `false` → केवल UI, HTTP, और CLI (मॉडल, MCP, और A2A से छिपा हुआ) | UI-only या प्रोग्रामेटिक actions जिन्हें टूल स्लॉट नहीं लेना चाहिए | +| `mcpTool` | `agentTool` | `false` → केवल आपकी अपनी app का एजेंट (MCP और A2A से छिपा हुआ) | ऐसे actions जिन्हें दूसरी ओर app की स्क्रीन या सेशन चाहिए | | `toolCallable` | `true` | `false` → sandboxed extension iframe ब्रिज **को छोड़कर** सब कुछ (403) | संवेदनशील account या org बदलाव (account delete करना, membership बदलना) | | `publicAgent` | off (private) | `{ expose: true }` → action को **public** MCP/A2A/OpenAPI सतहों में जोड़ता है | सुरक्षित read या ingest टूल जिन्हें authentication की आवश्यकता नहीं | | `needsApproval` | `false` | `true` → एजेंट **रुक जाता है**, और किसी मानव को उस विशिष्ट कॉल को अनुमोदित करना होगा | परिणामी side effects (email भेजना, कार्ड चार्ज करना, delete करना) | | `authorize` | none | एक गार्ड फ़ंक्शन → **हर** सतह पर केवल वे कॉलर्स जिन्हें यह स्वीकार करता है | किसी भूमिका तक प्रतिबंधित ऑपरेशंस (per-app RBAC, admin-only ऑप्स) | -ये flags स्वतंत्र हैं, इसलिए एक को सेट करने से बाकी नहीं बदलते। `agentTool` नियंत्रित करता है कि मॉडल क्या देखता है, नीचे [मॉडल से छिपाएं](#agent-tool) देखें। `toolCallable` केवल extension iframe को नियंत्रित करता है, नीचे [Extension iframes को ब्लॉक करें](#tool-callable) देखें। `publicAgent` एक opt-in public सतह जोड़ता है, और कोई public web route कभी public tool exposure का संकेत नहीं देता, नीचे [Public एजेंट्स के लिए एक्सपोज़ करें](#public-agent) देखें। `needsApproval` कॉल पहले से किए जाने के बाद execution को गेट करता है, नीचे [मानव अनुमोदन की आवश्यकता](#needs-approval) देखें। `authorize` तय करता है कि क्या यह कॉलर बिल्कुल भी action चला सकता है, हर dispatch पथ पर, `run()` में प्रवेश करने से पहले, नीचे [भूमिका के अनुसार कॉलर्स को प्रतिबंधित करें](#authorize) देखें। +ये flags स्वतंत्र हैं, एक अपवाद के साथ: बिना घोषित किया गया `mcpTool` `agentTool` से विरासत में लेता है। `agentTool` नियंत्रित करता है कि मॉडल क्या देखता है, नीचे [मॉडल से छिपाएं](#agent-tool) देखें। `mcpTool` इसे केवल बाहरी एजेंट्स तक सीमित करता है, नीचे [बाहरी एजेंट्स से छिपाएं](#mcp-tool) देखें। `toolCallable` केवल extension iframe को नियंत्रित करता है, नीचे [Extension iframes को ब्लॉक करें](#tool-callable) देखें। `publicAgent` एक opt-in public सतह जोड़ता है, और कोई public web route कभी public tool exposure का संकेत नहीं देता, नीचे [Public एजेंट्स के लिए एक्सपोज़ करें](#public-agent) देखें। `needsApproval` कॉल पहले से किए जाने के बाद execution को गेट करता है, नीचे [मानव अनुमोदन की आवश्यकता](#needs-approval) देखें। `authorize` तय करता है कि क्या यह कॉलर बिल्कुल भी action चला सकता है, हर dispatch पथ पर, `run()` में प्रवेश करने से पहले, नीचे [भूमिका के अनुसार कॉलर्स को प्रतिबंधित करें](#authorize) देखें। ## मॉडल से छिपाएं {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ इसका उपयोग तब करें जब आप कोई UI-only या पूरी तरह प्रोग्रामेटिक action जोड़ते हैं, या जब UI किसी ऐसे action का उपयोग करना बंद कर देता है जो अन्यथा मॉडल के सामने एक्सपोज़्ड रहता। +## बाहरी एजेंट्स से छिपाएं {#mcp-tool} + +`agentTool` सब-कुछ-या-कुछ-नहीं है: कोई action या तो हर एजेंट के लिए टूल है, या किसी के लिए नहीं। `mcpTool` इसे दो हिस्सों में बाँट देता है, ताकि कोई action आपकी अपनी app के एजेंट के लिए सामान्य टूल बना रहे और फिर भी MCP तथा A2A के ज़रिए Claude, Cursor या किसी सहोदर app तक कभी न पहुँचे: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +ऐसे actions के लिए इसका उपयोग करें जिनका परिणाम केवल आपकी app की UI, किसी खुले सेशन, या कॉलर के सामने मौजूद वर्तमान चयन के साथ ही अर्थ रखता है। `mcpTool` केवल `agentTool` को संकीर्ण कर सकता है, चौड़ा कभी नहीं: `agentTool: false` वाला action हर जगह छिपा रहता है। यह बाहरी टूल सतह को ही नियंत्रित करता है, यह नहीं कि किसी `ask_app` सवाल का जवाब देते समय आपकी अपनी app का एजेंट क्या कर सकता है — वह रन आपके एजेंट का है, और उसकी पूरी टूल सूची बनी रहती है। + +घोषित न करने पर `mcpTool` एक तयशुदा `true` के बजाय `agentTool` का अनुसरण करता है, इसलिए किसी action को एजेंट से छिपाना उसे बाहरी एजेंट्स से भी छिपा देता है और एक flag एक ही निर्णय बना रहता है। + +इसका उल्टा मान एक दूसरा काम करता है। बाहरी एजेंट्स को डिफ़ॉल्ट रूप से आपकी पूरी action रजिस्ट्री नहीं, बल्कि एक छोटी, चुनी हुई सूची दी जाती है, और `mcpTool: true` वह तरीका है जिससे कोई action खुद को उसका हिस्सा घोषित करता है: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +यह प्लगइन की `mcp: { connectorCatalog: [...] }` सूची वाली वही सूची है, बस इसे एक अलग array के बजाय action पर ही घोषित किया जाता है — उस array को actions के नाम बदलने पर हर बार सिंक में रखना पड़ता है। दोनों तरीके काम करते हैं, और कोई app एक-एक action करके माइग्रेट कर सकती है। जो छूट जाता है वह `tool-search` से खोजा जा सकता रहता है। + +दोनों flags को उल्टा जोड़ें — `agentTool: false` के साथ `mcpTool: true` — तो action **केवल-MCP** बन जाता है: बाहरी एजेंट्स को यह मिलता है, आपकी अपनी app का एजेंट इसे कभी नहीं देखता। ऐसे export, sync या handoff के लिए इसका उपयोग करें जिसकी ज़रूरत किसी बाहरी टूल को है और जिसे कॉल करने का आपके अपने एजेंट के पास कोई कारण नहीं। + +सूची में होना अनुमति नहीं है। बाहरी कॉलर को अब भी OAuth scope जाँच और `externalAgents` नीति से गुज़रना होता है, और मॉडल-रहित सीधे A2A पथ के लिए नीचे दिए गए `publicAgent` opt-in से भी। + ## Extension iframes को ब्लॉक करें {#tool-callable} Extensions ([sandboxed iframes में Alpine.js mini-apps](/docs/extensions)) `appAction(name, params)` के माध्यम से actions को कॉल करते हैं, viewer की अपनी permissions, secrets, और SQL scope के साथ चलते हुए। संवेदनशील ऑपरेशंस के लिए, डिफ़ॉल्ट रूप से यह बहुत अधिक trust है। extension ब्रिज को 403 लौटाने के लिए `toolCallable: false` सेट करें, जबकि action को UI, एजेंट, CLI, MCP, और A2A से कॉल करने योग्य बनाए रखते हुए: diff --git a/packages/core/docs/content/locales/hi-IN/actions-defining.mdx b/packages/core/docs/content/locales/hi-IN/actions-defining.mdx index d95f8d3a56..486e2423d5 100644 --- a/packages/core/docs/content/locales/hi-IN/actions-defining.mdx +++ b/packages/core/docs/content/locales/hi-IN/actions-defining.mdx @@ -115,6 +115,21 @@ description: "defineAction() की संरचना: schema, run, और HTTP description: "हर एजेंट टूल लिस्ट से छिपाने के लिए false सेट करें। Access & Authorization देखें।", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "`agentTool` से विरासत में लेता है। MCP और A2A के ज़रिए बाहरी एजेंट्स से छिपाने के लिए false करें, या चुनी हुई सूची में सदस्यता घोषित करने के लिए true करें। Access और Authorization देखें।", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "false करने पर action एजेंट की पहली टूल सूची में बना रहता है, और true करने पर वह tool-search के ज़रिए ज़रूरत पड़ने पर लोड होता है। नीचे “action सतह को छोटा रखें” देखें।", + }, { name: "toolCallable", type: "boolean", @@ -261,6 +276,7 @@ POST डिफ़ॉल्ट है, और `http: { method: "GET" }` इसे - कई प्रति-फ़ील्ड actions जैसे `update-name`, `update-order`, और `update-color` के बजाय, ऑप्शनल फ़ील्ड्स का एक patch लेने वाले **एक CRUD-स्टाइल `update` action** को प्राथमिकता दें। कॉलर केवल बदले गए फ़ील्ड्स भेजता है। - हर query या filter के लिए एक नया read action जोड़ने से पहले, पहले एक अधिक सामान्य विकल्प पर विचार करें: provider डेटा के लिए [provider API trio](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog`, `provider-api-docs`, और `provider-api-request`), या ऐप डेटा के लिए dev `db-query` टूल। - UI-only या प्रोग्रामेटिक actions को [`agentTool: false`](/docs/actions-access-control#agent-tool) चिह्नित करें। वे मॉडल की tool list में स्लॉट लिए बिना फ्रंटएंड से या HTTP पर कॉल करने योग्य बने रहते हैं। +- जिन गिने-चुने actions तक एजेंट लगातार पहुँचता है, उन्हें `deferLoading: false` से चिह्नित करें। पहली मॉडल रिक्वेस्ट में यही schemas भेजे जाते हैं; बाकी सब ज़रूरत पड़ने पर `tool-search` के ज़रिए लोड होते हैं। जैसे ही एक भी action इस तरह बाहर होता है, हर बिना चिह्न वाला action टाल दिया जाता है, इसलिए पूरा शुरुआती सेट एक साथ चिह्नित करें, एक-एक करके नहीं। यह पहुँच का नहीं, पहले टर्न की लागत का मामला है: टाला गया action भी उसी क्षण कॉल किया जा सकता है जब `tool-search` उसे लौटाता है। यह प्लगइन के `initialToolNames` array का per-action रूप है, और कोई app दोनों में से कोई भी इस्तेमाल कर सकती है। - उन actions को हटाएं या छिपाएं जिन्हें UI अब उपयोग नहीं करता, बजाय उन्हें मॉडल के सामने एक्सपोज़्ड छोड़ने के। रेपो में एक सलाहकार हेल्पर शामिल है, `node scripts/audit-template-actions.mjs [template ...]` (उपनाम `pnpm actions:audit`)। यह किसी टेम्पलेट के `actions/` फ़ोल्डर को स्कैन करता है और उन actions को फ़्लैग करता है जिन्हें UI अब उपयोग नहीं करता होगा, साथ ही प्रति-फ़ील्ड actions के उन समूहों को जिन्हें एक में जोड़ा जा सकता है। यह हमेशा कोड 0 के साथ exit होता है और कभी CI को फेल नहीं करता, और इसकी heuristics रूढ़िवादी हैं, इसलिए इसके आउटपुट को एक सुझाव मानें, error नहीं। diff --git a/packages/core/docs/content/locales/ja-JP/actions-access-control.mdx b/packages/core/docs/content/locales/ja-JP/actions-access-control.mdx index c4e2a6e6f8..d0b9a44e03 100644 --- a/packages/core/docs/content/locales/ja-JP/actions-access-control.mdx +++ b/packages/core/docs/content/locales/ja-JP/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "アクセスと認可" -description: "誰がアクションを呼び出せるか: 公開フラグ (agentTool、toolCallable)、ctx によるスコープ設定、accessFilter/assertAccess、authorize ガード。" +description: "誰がアクションを呼び出せるか: 公開フラグ (agentTool、mcpTool、toolCallable)、ctx によるスコープ設定、accessFilter/assertAccess、authorize ガード。" --- # アクセスと認可 -誰がアクションを呼び出せるかは、5つの設定によって決まります。エージェント、フロントエンド、拡張機能、外部の API 呼び出し元、あるいは人間の承認がない限り誰も呼び出せない、というものです。通常の UI とエージェントの利用にはデフォルトのままで十分なので、ほとんどのアクションはこれらに触れる必要がありません。 +誰がアクションを呼び出せるかは、6つの設定によって決まります。エージェント、フロントエンド、拡張機能、外部の API 呼び出し元、あるいは人間の承認がない限り誰も呼び出せない、というものです。通常の UI とエージェントの利用にはデフォルトのままで十分なので、ほとんどのアクションはこれらに触れる必要がありません。 ### 公開フラグ {#exposure-flags} -5つのフラグはすべて、デフォルトで許可寄りの値になっています。特定のサーフェスを絞りたいときだけ設定してください。以下の表は簡単なまとめです。その後のセクションで、各フラグに必要な詳細を1つずつ補足します。 +6つのフラグはすべて、デフォルトで許可寄りの値になっています (`mcpTool` は `agentTool` を継承します)。特定のサーフェスを絞りたいときだけ設定してください。以下の表は簡単なまとめです。その後のセクションで、各フラグに必要な詳細を1つずつ補足します。 | フラグ | デフォルト | 制限値 → それでも呼び出せる相手 | 典型的な用途 | | --------------- | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `agentTool` | `true` | `false` → UI、HTTP、CLI のみ (モデル、MCP、A2A からは非表示) | ツール枠を消費すべきでない、UI 専用またはプログラム的なアクション | +| `mcpTool` | `agentTool` | `false` → 自分のアプリのエージェントのみ (MCP と A2A からは非表示) | 呼び出し側にアプリの画面やセッションが必要なアクション | | `toolCallable` | `true` | `false` → サンドボックス化された拡張機能の iframe ブリッジ**以外**のすべて (403) | 機密性の高いアカウントや組織の変更 (アカウント削除、メンバーシップ変更) | | `publicAgent` | オフ (非公開) | `{ expose: true }` → **公開** MCP/A2A/OpenAPI サーフェスにこのアクションを追加 | 認証を必要としない、安全な読み取りまたは取り込みツール | | `needsApproval` | `false` | `true` → エージェントが**一時停止**し、人間がその特定の呼び出しを承認する必要がある | 重大な副作用を伴う操作 (メール送信、カードへの課金、削除) | | `authorize` | なし | ガード関数 → **すべて**のサーフェスで、そのガードが認めた呼び出し元のみ | 特定のロールに制限された操作 (アプリごとの RBAC、管理者専用の操作) | -これらのフラグは互いに独立しているため、1つを設定しても他には影響しません。`agentTool` はモデルに何が見えるかを制御します。下記の[モデルから隠す](#agent-tool)を参照してください。`toolCallable` は拡張機能の iframe のみを制御します。下記の[拡張機能の iframe をブロックする](#tool-callable)を参照してください。`publicAgent` はオプトインの公開サーフェスを追加するものであり、公開 Web ルートが公開ツールの公開を意味することは決してありません。下記の[公開エージェントに公開する](#public-agent)を参照してください。`needsApproval` は、呼び出しがすでに行われた後の実行をゲートします。下記の[人間の承認を必須にする](#needs-approval)を参照してください。`authorize` は、`run()` に入る前に、すべてのディスパッチ経路で、この呼び出し元がそもそもこのアクションを実行してよいかを判定します。下記の[ロールで呼び出し元を制限する](#authorize)を参照してください。 +これらのフラグは互いに独立しています。例外は1つだけで、宣言されていない `mcpTool` は `agentTool` を継承します。`agentTool` はモデルに何が見えるかを制御します。下記の[モデルから隠す](#agent-tool)を参照してください。`mcpTool` はその範囲を外部エージェントだけに絞ります。下記の[外部エージェントから隠す](#mcp-tool)を参照してください。`toolCallable` は拡張機能の iframe のみを制御します。下記の[拡張機能の iframe をブロックする](#tool-callable)を参照してください。`publicAgent` はオプトインの公開サーフェスを追加するものであり、公開 Web ルートが公開ツールの公開を意味することは決してありません。下記の[公開エージェントに公開する](#public-agent)を参照してください。`needsApproval` は、呼び出しがすでに行われた後の実行をゲートします。下記の[人間の承認を必須にする](#needs-approval)を参照してください。`authorize` は、`run()` に入る前に、すべてのディスパッチ経路で、この呼び出し元がそもそもこのアクションを実行してよいかを判定します。下記の[ロールで呼び出し元を制限する](#authorize)を参照してください。 ## モデルから隠す {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ UI 専用または純粋にプログラム的なアクションを追加するとき、あるいは UI がアクションを使わなくなったのに、そのままではモデルに公開され続けてしまうときに使用してください。 +## 外部エージェントから隠す {#mcp-tool} + +`agentTool` は全か無かです。アクションはすべてのエージェントのツールになるか、どのエージェントのツールにもならないかのどちらかです。`mcpTool` はこれを二つに分け、アクションを自分のアプリのエージェントにとっては通常どおりのツールのまま残しつつ、MCP や A2A 経由で Claude、Cursor、姉妹アプリに届かないようにできます。 + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +呼び出し側の目の前にあるアプリの UI、開いているセッション、現在の選択と組み合わせて初めて意味を持つ結果を返すアクションに使ってください。`mcpTool` は `agentTool` を狭めることしかできず、広げることはできません。`agentTool: false` のアクションはどこからも隠されたままです。これが制御するのは外部向けのツール サーフェスそのものであり、`ask_app` の質問に答えている最中に自分のアプリのエージェントが何をできるかではありません。その実行は自分のエージェントのものであり、ツール一覧はすべて保持されます。 + +宣言しない場合、`mcpTool` は一律の `true` ではなく `agentTool` の値を引き継ぎます。アクションをエージェントから隠せば、外部エージェントからも隠れるということです。フラグ1つが判断1つのままになります。 + +反対の値には別の役割があります。外部エージェントには、アクション レジストリ全体ではなく、小さく厳選されたカタログがデフォルトで提供されます。`mcpTool: true` は、アクション自身がそのカタログに属することを宣言する方法です。 + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +これはプラグインの `mcp: { connectorCatalog: [...] }` のリストと同じカタログですが、アクションの名前が変わるたびに同期が必要な別の配列ではなく、アクション側で宣言します。どちらの書き方も有効で、アプリは1つずつ移行できます。含めなかったものも `tool-search` から引き続き見つけられます。 + +2つのフラグを逆に組み合わせて `agentTool: false` と `mcpTool: true` にすると、そのアクションは **MCP 専用** になります。外部エージェントは使えますが、自分のアプリのエージェントには一切見えません。外部ツールが必要とし、自分のエージェントが呼ぶ理由のないエクスポート、同期、引き継ぎに使ってください。 + +カタログに含まれることは権限ではありません。外部の呼び出し元は、依然として OAuth スコープの検査と `externalAgents` ポリシーを、そしてモデルを介さない直接の A2A 経路では下記の `publicAgent` のオプトインも通過する必要があります。 + ## 拡張機能の iframe をブロックする {#tool-callable} 拡張機能 ([サンドボックス化された iframe 内の Alpine.js ミニアプリ](/docs/extensions)) は、`appAction(name, params)` を通じてアクションを呼び出し、閲覧者自身の権限、シークレット、SQL スコープで実行されます。機密性の高い操作にとって、これはデフォルトでは信頼しすぎです。`toolCallable: false` を設定すると、拡張機能ブリッジは 403 を返すようになりますが、UI、エージェント、CLI、MCP、A2A からは引き続き呼び出せます。 diff --git a/packages/core/docs/content/locales/ja-JP/actions-defining.mdx b/packages/core/docs/content/locales/ja-JP/actions-defining.mdx index 4eb89f00ab..092b267cd8 100644 --- a/packages/core/docs/content/locales/ja-JP/actions-defining.mdx +++ b/packages/core/docs/content/locales/ja-JP/actions-defining.mdx @@ -115,6 +115,21 @@ description: "defineAction() の構造 (スキーマ、run、HTTP 設定) と、 description: "`false` に設定すると、すべてのエージェント ツール一覧から非表示になります。「アクセスと認可」を参照してください。", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "`agentTool` を継承します。`false` に設定すると、MCP と A2A 経由の外部エージェントから非表示になります。`true` に設定すると、厳選されたカタログに属することを宣言します。「アクセスと認可」を参照してください。", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "`false` に設定すると、そのアクションはエージェントの最初のツール一覧に残ります。`true` に設定すると、`tool-search` を通じて必要になった時点で読み込まれます。下記の「アクションのサーフェスを小さく保つ」を参照してください。", + }, { name: "toolCallable", type: "boolean", @@ -261,6 +276,7 @@ POST がデフォルトで、`http: { method: "GET" }` を指定すると GET - `update-name`、`update-order`、`update-color` のようなフィールドごとの複数のアクションよりも、オプション フィールドのパッチを受け取る **CRUD スタイルの `update` アクションを1つ** 用意することを優先してください。呼び出し元は、変更されたフィールドだけを送信すればよくなります。 - クエリやフィルターごとに新しい読み取りアクションを追加する前に、まずより汎用的な選択肢を検討してください。プロバイダー データには [provider API トリオ](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog`、`provider-api-docs`、`provider-api-request`)、アプリ データには開発用の `db-query` ツールがあります。 - UI 専用またはプログラム的なアクションには、[`agentTool: false`](/docs/actions-access-control#agent-tool) を付けてください。フロントエンドや HTTP からは引き続き呼び出せますが、モデルのツール一覧の枠は消費しません。 +- エージェントが常に使うごく少数のアクションには `deferLoading: false` を付けてください。最初のモデル リクエストで送られるのは、それらのスキーマです。それ以外は、エージェントが必要としたときに `tool-search` を通じてオンデマンドで読み込まれます。1つでもこの形で除外すると、印の付いていないアクションはすべて後回しになるので、1つずつではなく、開始時に必要な一式をまとめて印を付けてください。これはアクセス制御ではなく、最初のターンのコストの話です。後回しにしたアクションも、`tool-search` が返した時点で呼び出せます。プラグインの `initialToolNames` 配列をアクション側で表現したもので、アプリはどちらを使ってもかまいません。 - UI がもう使わなくなったアクションは、モデルに公開したままにせず、削除するか非表示にしてください。 このリポジトリには、助言用のヘルパー `node scripts/audit-template-actions.mjs [template ...]` (エイリアス `pnpm actions:audit`) が含まれています。これはテンプレートの `actions/` フォルダをスキャンし、UI がもう使っていない可能性のあるアクションと、1つにまとめられそうなフィールドごとのアクション群にフラグを立てます。常に終了コード 0 を返して CI を落とすことはなく、そのヒューリスティックは控えめなので、その出力はエラーではなく提案として扱ってください。 diff --git a/packages/core/docs/content/locales/ko-KR/actions-access-control.mdx b/packages/core/docs/content/locales/ko-KR/actions-access-control.mdx index 54c0018e87..cc7f846676 100644 --- a/packages/core/docs/content/locales/ko-KR/actions-access-control.mdx +++ b/packages/core/docs/content/locales/ko-KR/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "액세스 및 권한 부여" -description: "누가 action을 호출할 수 있는지: 노출 플래그(agentTool, toolCallable), ctx 기반 범위 지정, accessFilter/assertAccess, authorize 가드." +description: "누가 action을 호출할 수 있는지: 노출 플래그(agentTool, mcpTool, toolCallable), ctx 기반 범위 지정, accessFilter/assertAccess, authorize 가드." --- # 액세스 및 권한 부여 -action을 호출할 수 있는 대상은 다섯 가지 설정으로 결정됩니다: 에이전트, 프런트엔드, 확장, 외부 API 호출자, 그리고 사람의 승인 없이는 아무도 호출할 수 없는 경우입니다. 기본값이 이미 일반적인 UI-및-에이전트 사용 사례에 맞기 때문에, 대부분의 action은 이 중 어느 것도 건드릴 필요가 없습니다. +action을 호출할 수 있는 대상은 여섯 가지 설정으로 결정됩니다: 에이전트, 프런트엔드, 확장, 외부 API 호출자, 그리고 사람의 승인 없이는 아무도 호출할 수 없는 경우입니다. 기본값이 이미 일반적인 UI-및-에이전트 사용 사례에 맞기 때문에, 대부분의 action은 이 중 어느 것도 건드릴 필요가 없습니다. ### 노출 플래그 {#exposure-flags} -다섯 플래그 모두 기본값은 허용적인 쪽이므로, 특정 표면을 조여야 할 때만 하나를 설정하면 됩니다. 아래 표는 간단한 요약입니다. 그 뒤 섹션들은 각 플래그에 필요한 세부 사항 한 가지씩을 추가로 다룹니다. +여섯 플래그 모두 기본값은 허용적인 쪽이고 `mcpTool`은 `agentTool`을 물려받으므로, 특정 표면을 조여야 할 때만 하나를 설정하면 됩니다. 아래 표는 간단한 요약입니다. 그 뒤 섹션들은 각 플래그에 필요한 세부 사항 한 가지씩을 추가로 다룹니다. | 플래그 | 기본값 | 제한 값 → 여전히 호출 가능한 대상 | 일반적인 용도 | | --------------- | ------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | | `agentTool` | `true` | `false` → UI, HTTP, CLI만 (모델, MCP, A2A에서는 숨겨짐) | 도구 슬롯을 차지하면 안 되는 UI 전용 또는 프로그래밍 방식 action | +| `mcpTool` | `agentTool` | `false` → 자체 앱의 에이전트만 (MCP와 A2A에서는 숨겨짐) | 상대편에 앱 화면이나 세션이 필요한 action | | `toolCallable` | `true` | `false` → 샌드박스된 확장 iframe 브리지를 **제외한** 모든 것 (403) | 민감한 계정/조직 변경 (계정 삭제, 멤버십 변경) | | `publicAgent` | 꺼짐(비공개) | `{ expose: true }` → **공개** MCP/A2A/OpenAPI 표면에 이 action을 추가 | 인증이 필요 없는 안전한 읽기 또는 수집 도구 | | `needsApproval` | `false` | `true` → 에이전트가 **일시 정지**하고, 사람이 이 특정 호출을 승인해야 함 | 결과가 큰 부작용 (이메일 전송, 카드 청구, 삭제) | | `authorize` | 없음 | 가드 함수 → **모든** 표면에서 이 함수가 허용하는 호출자만 통과 | 특정 역할로 제한되는 작업 (앱별 RBAC, 관리자 전용 작업) | -이 플래그들은 서로 독립적이므로, 하나를 설정해도 다른 것들은 바뀌지 않습니다. `agentTool`은 모델이 무엇을 보는지를 제어합니다. 아래 [모델에서 숨기기](#agent-tool)를 참고하세요. `toolCallable`은 확장 iframe만 제어합니다. 아래 [확장 iframe 차단하기](#tool-callable)를 참고하세요. `publicAgent`는 옵트인 방식의 공개 표면을 추가하며, 공개 웹 라우트라고 해서 도구까지 공개되는 것은 결코 아닙니다. 아래 [공개 에이전트에 노출하기](#public-agent)를 참고하세요. `needsApproval`은 호출이 이미 이루어진 뒤 실행을 게이트합니다. 아래 [사람의 승인 요구하기](#needs-approval)를 참고하세요. `authorize`는 `run()`에 진입하기 전, 모든 디스패치 경로에서 이 호출자가 애초에 이 action을 실행할 수 있는지를 결정합니다. 아래 [역할로 호출자 제한하기](#authorize)를 참고하세요. +이 플래그들은 서로 독립적이지만 예외가 하나 있습니다. 선언하지 않은 `mcpTool`은 `agentTool`을 물려받습니다. `agentTool`은 모델이 무엇을 보는지를 제어합니다. 아래 [모델에서 숨기기](#agent-tool)를 참고하세요. `mcpTool`은 그 범위를 외부 에이전트로만 좁힙니다. 아래 [외부 에이전트에서 숨기기](#mcp-tool)를 참고하세요. `toolCallable`은 확장 iframe만 제어합니다. 아래 [확장 iframe 차단하기](#tool-callable)를 참고하세요. `publicAgent`는 옵트인 방식의 공개 표면을 추가하며, 공개 웹 라우트라고 해서 도구까지 공개되는 것은 결코 아닙니다. 아래 [공개 에이전트에 노출하기](#public-agent)를 참고하세요. `needsApproval`은 호출이 이미 이루어진 뒤 실행을 게이트합니다. 아래 [사람의 승인 요구하기](#needs-approval)를 참고하세요. `authorize`는 `run()`에 진입하기 전, 모든 디스패치 경로에서 이 호출자가 애초에 이 action을 실행할 수 있는지를 결정합니다. 아래 [역할로 호출자 제한하기](#authorize)를 참고하세요. ## 모델에서 숨기기 {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ UI 전용이거나 순수하게 프로그래밍 방식인 action을 추가할 때, 또는 UI가 더 이상 사용하지 않아 모델에만 계속 노출되는 것을 막고 싶을 때 사용하세요. +## 외부 에이전트에서 숨기기 {#mcp-tool} + +`agentTool`은 전부 아니면 전무입니다. action은 모든 에이전트의 도구이거나, 어느 에이전트의 도구도 아닙니다. `mcpTool`은 이를 둘로 나눠서, action이 자체 앱의 에이전트에게는 평범한 도구로 남으면서도 MCP와 A2A를 통해 Claude, Cursor, 형제 앱에는 결코 도달하지 않게 합니다: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +호출자 앞에 있는 앱 UI, 열려 있는 세션, 현재 선택과 함께여야만 의미가 있는 결과를 내는 action에 사용하세요. `mcpTool`은 `agentTool`을 좁힐 수만 있고 넓힐 수는 없습니다. `agentTool: false`인 action은 어디에서나 계속 숨겨집니다. 이것이 다스리는 것은 외부 도구 표면 자체이지, `ask_app` 질문에 답하는 동안 자체 에이전트가 무엇을 할 수 있는지가 아닙니다. 그 실행은 자체 에이전트의 것이며, 도구 목록을 그대로 유지합니다. + +선언하지 않으면 `mcpTool`은 일률적인 `true`가 아니라 `agentTool`을 따릅니다. 그래서 어떤 action을 에이전트에서 숨기면 외부 에이전트에서도 숨겨지고, 플래그 하나가 결정 하나로 유지됩니다. + +반대 값에는 또 다른 역할이 있습니다. 외부 에이전트에는 기본적으로 전체 action 레지스트리가 아니라 작고 엄선된 카탈로그가 제공되며, `mcpTool: true`는 action이 스스로 그 카탈로그의 일부임을 선언하는 방법입니다: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +이는 플러그인의 `mcp: { connectorCatalog: [...] }` 목록과 같은 카탈로그이며, action 이름이 바뀔 때마다 맞춰 줘야 하는 별도 배열 대신 action에 직접 선언합니다. 두 방식 모두 동작하므로 앱은 한 번에 하나씩 옮길 수 있습니다. 빠진 것들도 `tool-search`로 계속 찾을 수 있습니다. + +두 플래그를 반대로 짝지어 `agentTool: false`와 `mcpTool: true`를 함께 쓰면 그 action은 **MCP 전용**이 됩니다. 외부 에이전트는 받고, 자체 앱의 에이전트는 결코 보지 못합니다. 외부 도구에는 필요하지만 자체 에이전트가 호출할 이유가 없는 내보내기, 동기화, 인계 작업에 사용하세요. + +카탈로그에 속한다는 것이 권한을 뜻하지는 않습니다. 외부 호출자는 여전히 OAuth 스코프 검사와 `externalAgents` 정책을 통과해야 하며, 모델을 거치지 않는 직접 A2A 경로에서는 아래의 `publicAgent` 옵트인도 통과해야 합니다. + ## 확장 iframe 차단하기 {#tool-callable} 확장([샌드박스된 iframe 안의 Alpine.js 미니 앱](/docs/extensions))은 `appAction(name, params)`을 통해 action을 호출하며, 이때 뷰어 본인의 권한, 비밀, SQL 범위로 실행됩니다. 민감한 작업에는 이 정도 신뢰가 기본값으로는 과합니다. `toolCallable: false`로 설정하면 확장 브리지가 403을 반환하게 되지만, UI, 에이전트, CLI, MCP, A2A에서는 여전히 이 action을 호출할 수 있습니다. diff --git a/packages/core/docs/content/locales/ko-KR/actions-defining.mdx b/packages/core/docs/content/locales/ko-KR/actions-defining.mdx index e99919783e..210e2d436c 100644 --- a/packages/core/docs/content/locales/ko-KR/actions-defining.mdx +++ b/packages/core/docs/content/locales/ko-KR/actions-defining.mdx @@ -115,6 +115,21 @@ description: "defineAction()의 구조: schema, run, HTTP 설정, 그리고 acti description: "모든 에이전트 도구 목록에서 숨기려면 false로 설정하세요. 액세스 및 권한 부여를 참고하세요.", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "`agentTool`을 물려받습니다. false로 설정하면 MCP와 A2A를 통한 외부 에이전트에서 숨겨지고, true로 설정하면 엄선된 카탈로그 소속임을 선언합니다. 액세스 및 권한 부여를 참고하세요.", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "false로 설정하면 해당 action이 에이전트의 첫 도구 목록에 남고, true로 설정하면 tool-search를 통해 필요할 때 로드됩니다. 아래 action 표면을 작게 유지하기를 참고하세요.", + }, { name: "toolCallable", type: "boolean", @@ -261,6 +276,7 @@ POST가 기본값이며, `http: { method: "GET" }`을 지정하면 GET이 됩니 - `update-name`, `update-order`, `update-color`처럼 필드별로 action을 여러 개 만드는 대신, 선택적 필드로 이루어진 패치를 받는 **하나의 CRUD 스타일 `update` action**을 선호하세요. 호출자는 바뀐 필드만 보내면 됩니다. - 모든 쿼리나 필터마다 새 읽기 action을 추가하기 전에, 더 범용적인 선택지를 먼저 고려하세요. 프로바이더 데이터에는 [provider API 트리오](/docs/template-dispatch-vault-integrations#provider-api)(`provider-api-catalog`, `provider-api-docs`, `provider-api-request`)를, 앱 데이터에는 개발용 `db-query` 도구를 사용하세요. - UI 전용이거나 프로그래밍 방식 action은 [`agentTool: false`](/docs/actions-access-control#agent-tool)로 표시하세요. 모델의 도구 목록에는 자리를 차지하지 않으면서, 프런트엔드나 HTTP를 통해서는 계속 호출할 수 있습니다. +- 에이전트가 늘 손을 뻗는 소수의 action에는 `deferLoading: false`를 표시하세요. 첫 모델 요청에 실려 가는 것이 바로 그 스키마들이고, 나머지는 에이전트가 필요로 할 때 `tool-search`를 통해 그때그때 로드됩니다. 한 action이라도 이렇게 빠지는 순간 표시하지 않은 action은 모두 뒤로 미뤄지므로, 한 번에 하나씩이 아니라 시작에 필요한 묶음을 한꺼번에 표시하세요. 이것은 접근 권한이 아니라 첫 턴의 비용에 대한 이야기입니다. 뒤로 미룬 action도 `tool-search`가 반환하는 순간 호출할 수 있습니다. 플러그인의 `initialToolNames` 배열을 action 단위로 표현한 것이며, 앱은 둘 중 어느 쪽이든 쓸 수 있습니다. - UI가 더 이상 사용하지 않는 action은 모델에 노출된 채로 두지 말고 삭제하거나 숨기세요. 이 저장소에는 참고용 도우미 `node scripts/audit-template-actions.mjs [template ...]`(별칭 `pnpm actions:audit`)가 포함되어 있습니다. 이 스크립트는 템플릿의 `actions/` 폴더를 스캔하여 UI가 더 이상 사용하지 않을 수 있는 action과, 하나로 합칠 수 있는 필드별 action 그룹을 알려줍니다. 항상 종료 코드 0으로 끝나며 CI를 실패시키지 않고, 휴리스틱도 보수적이므로 그 출력은 오류가 아니라 제안으로 다루세요. diff --git a/packages/core/docs/content/locales/pt-BR/actions-access-control.mdx b/packages/core/docs/content/locales/pt-BR/actions-access-control.mdx index a5205adb08..ccaaed186b 100644 --- a/packages/core/docs/content/locales/pt-BR/actions-access-control.mdx +++ b/packages/core/docs/content/locales/pt-BR/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "Acesso e Autorização" -description: "Quem pode chamar uma action: flags de exposição (agentTool, toolCallable), escopo baseado em ctx, accessFilter/assertAccess, e a guarda authorize." +description: "Quem pode chamar uma action: flags de exposição (agentTool, mcpTool, toolCallable), escopo baseado em ctx, accessFilter/assertAccess, e a guarda authorize." --- # Acesso e Autorização -Cinco configurações decidem quem pode chamar uma action: o agente, o frontend, uma extensão, um chamador de API externo, ou ninguém sem a aprovação de um humano primeiro. A maioria das actions nunca precisa mexer em nenhuma delas, já que os padrões já se encaixam no uso normal de UI e agente. +Seis configurações decidem quem pode chamar uma action: o agente, o frontend, uma extensão, um chamador de API externo, ou ninguém sem a aprovação de um humano primeiro. A maioria das actions nunca precisa mexer em nenhuma delas, já que os padrões já se encaixam no uso normal de UI e agente. ### Flags de exposição {#exposure-flags} -As cinco flags têm como padrão o valor permissivo, então você só define uma quando precisa restringir uma superfície específica. A tabela abaixo é um resumo rápido. As seções depois dela acrescentam o único detalhe que cada flag precisa. +As seis têm como padrão o valor permissivo — `mcpTool` herdando `agentTool` —, então você só define uma quando precisa restringir uma superfície específica. A tabela abaixo é um resumo rápido. As seções depois dela acrescentam o único detalhe que cada flag precisa. | Flag | Padrão | Valor restritivo → quem ainda pode chamar | Uso típico | | --------------- | ------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `agentTool` | `true` | `false` → apenas UI, HTTP e CLI (oculta do modelo, MCP e A2A) | Actions apenas de UI ou programáticas que não devem ocupar um slot de ferramenta | +| `mcpTool` | `agentTool` | `false` → apenas o agente do seu próprio app (oculta de MCP e A2A) | Actions que precisam de uma tela ou sessão do app do outro lado | | `toolCallable` | `true` | `false` → tudo **exceto** a ponte de iframe de extensão em sandbox (403) | Mudanças sensíveis de conta ou org (excluir conta, mudar associação) | | `publicAgent` | desligado (privado) | `{ expose: true }` → adiciona a action às superfícies **públicas** de MCP/A2A/OpenAPI | Ferramentas seguras de leitura ou ingestão que não exigem autenticação | | `needsApproval` | `false` | `true` → o agente **pausa**, e um humano precisa aprovar a chamada específica | Efeitos colaterais consequenciais (enviar e-mail, cobrar um cartão, excluir) | | `authorize` | nenhum | uma função guarda → apenas os chamadores que ela aceita, em **toda** superfície | Operações restritas a uma função (RBAC por app, operações só para admin) | -Essas flags são independentes, então definir uma não muda as outras. `agentTool` controla o que o modelo vê, veja [Ocultar do modelo](#agent-tool) abaixo. `toolCallable` controla apenas o iframe de extensão, veja [Bloquear iframes de extensão](#tool-callable) abaixo. `publicAgent` adiciona uma superfície pública opt-in, e uma rota web pública nunca implica exposição pública de ferramenta, veja [Expor a agentes públicos](#public-agent) abaixo. `needsApproval` restringe a execução depois que a chamada já foi feita, veja [Exigir aprovação humana](#needs-approval) abaixo. `authorize` decide se este chamador pode executar a action, em todo caminho de despacho, antes de `run()` ser executado, veja [Restringir chamadores por função](#authorize) abaixo. +Essas flags são independentes, com uma exceção: um `mcpTool` não declarado herda `agentTool`. `agentTool` controla o que o modelo vê, veja [Ocultar do modelo](#agent-tool) abaixo. `mcpTool` restringe isso apenas aos agentes externos, veja [Ocultar de agentes externos](#mcp-tool) abaixo. `toolCallable` controla apenas o iframe de extensão, veja [Bloquear iframes de extensão](#tool-callable) abaixo. `publicAgent` adiciona uma superfície pública opt-in, e uma rota web pública nunca implica exposição pública de ferramenta, veja [Expor a agentes públicos](#public-agent) abaixo. `needsApproval` restringe a execução depois que a chamada já foi feita, veja [Exigir aprovação humana](#needs-approval) abaixo. `authorize` decide se este chamador pode executar a action, em todo caminho de despacho, antes de `run()` ser executado, veja [Restringir chamadores por função](#authorize) abaixo. ## Ocultar do modelo {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ Use isso quando adicionar uma action apenas de UI ou puramente programática, ou quando a UI parar de usar uma action que de outra forma continuaria exposta ao modelo. +## Ocultar de agentes externos {#mcp-tool} + +`agentTool` é tudo ou nada: uma action é uma ferramenta para todo agente, ou para nenhum. `mcpTool` divide isso em dois, de modo que uma action pode continuar sendo uma ferramenta normal para o agente do seu próprio app sem nunca chegar ao Claude, ao Cursor ou a um app irmão por MCP e A2A: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +Use para actions cujo resultado só faz sentido junto com a UI do seu app, uma sessão aberta ou a seleção atual diante do chamador. `mcpTool` só pode restringir `agentTool`, nunca ampliá-lo: uma action com `agentTool: false` continua oculta em todo lugar. Ele governa a própria superfície de ferramentas externa, não o que o seu próprio agente pode fazer ao responder a uma pergunta de `ask_app` — essa execução é do seu agente, e ele mantém a lista de ferramentas inteira. + +Sem declaração, `mcpTool` segue `agentTool` em vez de assumir um `true` fixo, então ocultar uma action do agente também a oculta dos agentes externos e uma flag continua sendo uma decisão só. + +O valor oposto tem uma segunda função. Agentes externos recebem por padrão um catálogo pequeno e curado em vez de todo o seu registro de actions, e `mcpTool: true` é como uma action se declara parte dele: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +É o mesmo catálogo da lista `mcp: { connectorCatalog: [...] }` do plugin, declarado na action em vez de em um array separado que precisa ser mantido em sincronia conforme as actions são renomeadas. As duas formas funcionam, e um app pode migrar uma action de cada vez. Tudo que fica de fora continua descobrível pelo `tool-search`. + +Combinar as duas flags ao contrário — `agentTool: false` com `mcpTool: true` — torna a action **exclusiva de MCP**: agentes externos a recebem e o agente do seu próprio app nunca a vê. Use para a exportação, a sincronização ou a entrega que uma ferramenta externa precisa e que o seu agente não tem motivo para chamar. + +Estar no catálogo não é permissão. Um chamador externo ainda precisa passar pela verificação de escopo OAuth, pela política `externalAgents` e — no caminho A2A direto, sem modelo — pelo opt-in `publicAgent` abaixo. + ## Bloquear iframes de extensão {#tool-callable} Extensions ([mini-apps Alpine.js em iframes em sandbox](/docs/extensions)) chamam actions através de `appAction(name, params)`, rodando com as próprias permissões, secrets e escopo SQL de quem está vendo. Para operações sensíveis, isso é confiança demais por padrão. Defina `toolCallable: false` para fazer a ponte de extensão retornar um 403, mantendo a action chamável a partir da UI, do agente, da CLI, do MCP e do A2A: diff --git a/packages/core/docs/content/locales/pt-BR/actions-defining.mdx b/packages/core/docs/content/locales/pt-BR/actions-defining.mdx index 3c0f1dedb8..73bca9d76b 100644 --- a/packages/core/docs/content/locales/pt-BR/actions-defining.mdx +++ b/packages/core/docs/content/locales/pt-BR/actions-defining.mdx @@ -116,6 +116,21 @@ O framework descobre automaticamente cada arquivo em `actions/` e o monta na ini description: "Defina como false para ocultar de toda lista de ferramentas do agente. Veja Acesso e Autorização.", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "Herda `agentTool`. Defina false para ocultar de agentes externos por MCP e A2A, ou true para declarar participação no catálogo curado. Veja Acesso e Autorização.", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "Defina false para manter a action na primeira lista de ferramentas do agente, ou true para carregá-la sob demanda pelo tool-search. Veja Mantenha a superfície de actions pequena abaixo.", + }, { name: "toolCallable", type: "boolean", @@ -262,6 +277,7 @@ Toda action que o agente pode ver ocupa um slot na lista de ferramentas do model - Prefira **uma única action `update` no estilo CRUD** que receba um patch de campos opcionais, em vez de muitas actions por campo como `update-name`, `update-order` e `update-color`. O chamador só envia os campos que mudaram. - Antes de adicionar uma nova action de leitura para cada consulta ou filtro, considere primeiro uma opção mais geral: o [trio de API de provedor](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog`, `provider-api-docs` e `provider-api-request`) para dados de provedor, ou a ferramenta dev `db-query` para dados do app. - Marque actions apenas de UI ou programáticas como [`agentTool: false`](/docs/actions-access-control#agent-tool). Elas continuam chamáveis a partir do frontend ou por HTTP, sem ocupar um slot na lista de ferramentas do modelo. +- Marque com `deferLoading: false` o punhado de actions que o agente usa o tempo todo. São esses os schemas enviados na primeira requisição ao modelo; todo o resto carrega sob demanda pelo `tool-search` quando o agente precisa. Assim que uma action se exclui desse jeito, todas as não marcadas passam a ser adiadas, então marque o conjunto inicial inteiro de uma vez em vez de uma action por vez. Isso é sobre quanto custa o primeiro turno, não sobre acesso: uma action adiada é chamável assim que o `tool-search` a retorna. É a forma por action do array `initialToolNames` do plugin, e um app pode usar qualquer uma das duas. - Exclua ou oculte actions que a UI não usa mais, em vez de deixá-las expostas ao modelo. O repositório inclui um helper consultivo, `node scripts/audit-template-actions.mjs [template ...]` (alias `pnpm actions:audit`). Ele varre a pasta `actions/` de um template e sinaliza actions que a UI pode não estar mais usando, além de grupos de actions por campo que poderiam ser combinadas em uma só. Ele sempre sai com código 0 e nunca falha o CI, e suas heurísticas são conservadoras, então trate a saída dele como uma sugestão, não um erro. diff --git a/packages/core/docs/content/locales/zh-CN/actions-access-control.mdx b/packages/core/docs/content/locales/zh-CN/actions-access-control.mdx index 9d9ccd275f..d3baf597d0 100644 --- a/packages/core/docs/content/locales/zh-CN/actions-access-control.mdx +++ b/packages/core/docs/content/locales/zh-CN/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "访问与授权" -description: "谁可以调用一个 action:暴露 flag(agentTool、toolCallable)、基于 ctx 的范围界定、accessFilter/assertAccess,以及 authorize 守卫。" +description: "谁可以调用一个 action:暴露 flag(agentTool、mcpTool、toolCallable)、基于 ctx 的范围界定、accessFilter/assertAccess,以及 authorize 守卫。" --- # 访问与授权 -五个设置项决定谁可以调用一个 action:代理、前端、扩展、外部 API 调用者,或者在没有人工先批准的情况下谁都不行。大多数 action 从来不需要碰这些设置,因为默认值已经适合普通的 UI 和代理使用场景。 +六个设置项决定谁可以调用一个 action:代理、前端、扩展、外部 API 调用者,或者在没有人工先批准的情况下谁都不行。大多数 action 从来不需要碰这些设置,因为默认值已经适合普通的 UI 和代理使用场景。 ### 暴露 flag {#exposure-flags} -全部五个 flag 都默认取宽松值,所以只有在需要收紧某个特定界面时才需要设置其中之一。下表是一份速览,后面的小节会补充每个 flag 所需的那一个细节。 +全部六个 flag 都默认取宽松值——`mcpTool` 是通过继承 `agentTool` 取到的——所以只有在需要收紧某个特定界面时才需要设置其中之一。下表是一份速览,后面的小节会补充每个 flag 所需的那一个细节。 -| Flag | 默认值 | 限制值 → 谁仍然可以调用 | 典型用途 | -| --------------- | ---------- | ------------------------------------------------------------------ | ----------------------------------------------------- | -| `agentTool` | `true` | `false` → 仅 UI、HTTP 和 CLI(对模型、MCP 和 A2A 隐藏) | 不应占用工具位置的仅 UI 或程序化 action | -| `toolCallable` | `true` | `false` → 除沙盒化的扩展 iframe 桥接之外的一切(403) | 敏感的账户或组织变更(删除账户、变更成员关系) | -| `publicAgent` | 关闭(私有) | `{ expose: true }` → 把该 action 加入**公共** MCP/A2A/OpenAPI 界面 | 不需要身份验证的安全读取或摄取类工具 | -| `needsApproval` | `false` | `true` → 代理**暂停**,必须由人工批准这次具体的调用 | 有实际后果的副作用(发送邮件、扣款、删除) | -| `authorize` | 无 | 一个守卫函数 → 只有它接受的调用者才能在**每一个**界面上调用 | 限定于某个角色的操作(按应用划分的 RBAC、仅管理员操作) | +| Flag | 默认值 | 限制值 → 谁仍然可以调用 | 典型用途 | +| --------------- | ----------- | ------------------------------------------------------------------ | ----------------------------------------------------- | +| `agentTool` | `true` | `false` → 仅 UI、HTTP 和 CLI(对模型、MCP 和 A2A 隐藏) | 不应占用工具位置的仅 UI 或程序化 action | +| `mcpTool` | `agentTool` | `false` → 只有你自己应用的代理(对 MCP 和 A2A 隐藏) | 需要对端有应用界面或会话的 action | +| `toolCallable` | `true` | `false` → 除沙盒化的扩展 iframe 桥接之外的一切(403) | 敏感的账户或组织变更(删除账户、变更成员关系) | +| `publicAgent` | 关闭(私有) | `{ expose: true }` → 把该 action 加入**公共** MCP/A2A/OpenAPI 界面 | 不需要身份验证的安全读取或摄取类工具 | +| `needsApproval` | `false` | `true` → 代理**暂停**,必须由人工批准这次具体的调用 | 有实际后果的副作用(发送邮件、扣款、删除) | +| `authorize` | 无 | 一个守卫函数 → 只有它接受的调用者才能在**每一个**界面上调用 | 限定于某个角色的操作(按应用划分的 RBAC、仅管理员操作) | -这些 flag 相互独立,设置其中一个不会改变其他的。`agentTool` 控制模型能看到什么,见下方[对模型隐藏](#agent-tool)。`toolCallable` 只控制扩展 iframe,见下方[阻止扩展 iframe](#tool-callable)。`publicAgent` 增加了一个可选的公共界面,一个公开的网页路由从不意味着公开的工具暴露,见下方[对公共代理暴露](#public-agent)。`needsApproval` 是在调用已经发生之后才对执行进行门控,见下方[要求人工批准](#needs-approval)。`authorize` 决定这个调用者是否可以运行该 action,适用于每一条调用路径,在进入 `run()` 之前生效,见下方[按角色限制调用者](#authorize)。 +这些 flag 相互独立,只有一个例外:未声明的 `mcpTool` 会继承 `agentTool`。`agentTool` 控制模型能看到什么,见下方[对模型隐藏](#agent-tool)。`mcpTool` 把这一点进一步收紧到只针对外部代理,见下方[对外部代理隐藏](#mcp-tool)。`toolCallable` 只控制扩展 iframe,见下方[阻止扩展 iframe](#tool-callable)。`publicAgent` 增加了一个可选的公共界面,一个公开的网页路由从不意味着公开的工具暴露,见下方[对公共代理暴露](#public-agent)。`needsApproval` 是在调用已经发生之后才对执行进行门控,见下方[要求人工批准](#needs-approval)。`authorize` 决定这个调用者是否可以运行该 action,适用于每一条调用路径,在进入 `run()` 之前生效,见下方[按角色限制调用者](#authorize)。 ## 对模型隐藏 {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ 当你新增一个仅供 UI 使用或纯程序化的 action,或者当 UI 不再使用某个原本会一直暴露给模型的 action 时,使用它。 +## 对外部代理隐藏 {#mcp-tool} + +`agentTool` 是全有或全无:一个 action 要么对所有代理都是工具,要么对谁都不是。`mcpTool` 把它一分为二,让一个 action 对你自己应用的代理仍然是普通工具,同时永远不会经由 MCP 和 A2A 到达 Claude、Cursor 或兄弟应用: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +当一个 action 的结果只有配合你应用的 UI、一个打开的会话或调用者眼前的当前选择才有意义时,就用它。`mcpTool` 只能收紧 `agentTool`,不能放宽:声明了 `agentTool: false` 的 action 在任何地方都仍然隐藏。它管的是对外的工具界面本身,而不是你自己的代理在回答一次 `ask_app` 提问时能做什么——那次运行属于你自己的代理,它保留完整的工具列表。 + +不声明时,`mcpTool` 跟随 `agentTool`,而不是一律取 `true`:把一个 action 对代理隐藏,也就同时对外部代理隐藏了,一个 flag 仍然只对应一个决定。 + +相反的取值还有另一个用途。默认情况下,外部代理拿到的是一份精选的小目录,而不是你的整个 action 注册表,而 `mcpTool: true` 就是一个 action 声明自己属于该目录的方式: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +这与插件的 `mcp: { connectorCatalog: [...] }` 列表是同一份目录,只是声明写在 action 上,而不是写在一个每次重命名都要同步的独立数组里。两种写法都有效,应用可以一次迁移一个 action。没有列入的部分仍然可以通过 `tool-search` 发现。 + +把两个 flag 反过来搭配——`agentTool: false` 配 `mcpTool: true`——就得到一个**仅限 MCP** 的 action:外部代理拿得到,你自己应用的代理永远看不到。用它来做外部工具需要、而你自己的代理没有理由调用的导出、同步或交接。 + +在目录里并不等于有权限。外部调用者仍然要通过 OAuth scope 检查和 `externalAgents` 策略;在不经过模型的直接 A2A 路径上,还要通过下方的 `publicAgent` 选择加入。 + ## 阻止扩展 iframe {#tool-callable} 扩展([沙盒 iframe 中的 Alpine.js 迷你应用](/docs/extensions))通过 `appAction(name, params)` 调用 action,并以查看者自己的权限、密钥和 SQL 范围运行。对于敏感操作,默认给予的信任太多。设置 `toolCallable: false` 会让扩展桥接返回 403,同时保持该 action 仍可从 UI、代理、CLI、MCP 和 A2A 调用: diff --git a/packages/core/docs/content/locales/zh-CN/actions-defining.mdx b/packages/core/docs/content/locales/zh-CN/actions-defining.mdx index 4180ed2d69..0cba2fe6d3 100644 --- a/packages/core/docs/content/locales/zh-CN/actions-defining.mdx +++ b/packages/core/docs/content/locales/zh-CN/actions-defining.mdx @@ -111,6 +111,21 @@ description: "defineAction() 的结构:schema、run,以及 HTTP 配置,还有如 description: "设为 false 可以从每一个代理工具列表中隐藏。参见访问与授权。", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "继承 `agentTool`。设为 false 可对经由 MCP 和 A2A 的外部代理隐藏,设为 true 则声明它属于精选目录。见“访问与授权”。", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "设为 false 可让该 action 留在代理的第一份工具列表里,设为 true 则通过 tool-search 按需加载。见下方“让 action 界面保持精简”。", + }, { name: "toolCallable", type: "boolean", @@ -256,6 +271,7 @@ POST 是默认方法,`http: { method: "GET" }` 会让它变成 GET。React 钩 - 优先选择**一个 CRUD 风格的 `update` action**,它接受一份包含可选字段的补丁,而不是 `update-name`、`update-order`、`update-color` 这样按字段拆分的许多 action。调用者只需要发送发生变化的字段。 - 在为每一种查询或过滤条件新增一个读取类 action 之前,先考虑更通用的方案:面向 provider 数据的 [provider API 三件套](/docs/template-dispatch-vault-integrations#provider-api)(`provider-api-catalog`、`provider-api-docs` 和 `provider-api-request`),或者面向应用数据的开发用 `db-query` 工具。 - 把仅供 UI 使用或程序化调用的 action 标记为 [`agentTool: false`](/docs/actions-access-control#agent-tool)。它们仍然可以从前端或通过 HTTP 调用,但不会占用模型工具列表中的位置。 +- 把代理时时都要用的少数几个 action 标记为 `deferLoading: false`。第一次模型请求发送的正是这些 schema;其余的会在代理需要时通过 `tool-search` 按需加载。只要有一个 action 这样退出,所有未标记的 action 就都会被推迟,所以要一次性标完整套起步 action,而不是一次标一个。这关乎第一轮的开销,而不是访问权限:被推迟的 action 在 `tool-search` 返回它的那一刻就可以调用。它是插件 `initialToolNames` 数组的按 action 写法,应用两者用其一即可。 - 删除或隐藏 UI 已经不再使用的 action,而不是继续把它们暴露给模型。 仓库内置了一个仅供参考的辅助工具 `node scripts/audit-template-actions.mjs [template ...]`(别名 `pnpm actions:audit`)。它会扫描某个模板的 `actions/` 文件夹,标记出 UI 可能已经不再使用的 action,以及那些可以合并为一个的、按字段拆分的 action 组。它总是以退出码 0 结束,从不会让 CI 失败,而且它的启发式规则是保守的,所以请把它的输出当作建议,而不是错误。 diff --git a/packages/core/docs/content/locales/zh-TW/actions-access-control.mdx b/packages/core/docs/content/locales/zh-TW/actions-access-control.mdx index 8713b35541..dd3c9a77d4 100644 --- a/packages/core/docs/content/locales/zh-TW/actions-access-control.mdx +++ b/packages/core/docs/content/locales/zh-TW/actions-access-control.mdx @@ -1,25 +1,26 @@ --- title: "存取與授權" -description: "誰可以呼叫一個 action:曝露旗標(agentTool、toolCallable)、以 ctx 為基礎的範圍限定、accessFilter/assertAccess,以及 authorize 守衛。" +description: "誰可以呼叫一個 action:曝露旗標(agentTool、mcpTool、toolCallable)、以 ctx 為基礎的範圍限定、accessFilter/assertAccess,以及 authorize 守衛。" --- # 存取與授權 -五項設定決定誰可以呼叫一個 action:代理、前端、擴充功能、外部 API 呼叫者,或是在沒有人工先核准的情況下沒有任何人可以呼叫。大多數 action 永遠不需要碰這些設定,因為預設值已經符合一般 UI 與代理的使用情境。 +六項設定決定誰可以呼叫一個 action:代理、前端、擴充功能、外部 API 呼叫者,或是在沒有人工先核准的情況下沒有任何人可以呼叫。大多數 action 永遠不需要碰這些設定,因為預設值已經符合一般 UI 與代理的使用情境。 ### 曝露旗標 {#exposure-flags} -這五個旗標預設都是寬鬆值,因此只有在需要收緊特定介面時才需要設定其中一個。下方表格是快速摘要,其後的章節則補充每個旗標各自需要的細節。 +這六個旗標預設都是寬鬆值,其中 `mcpTool` 是繼承 `agentTool` 而來,因此只有在需要收緊特定介面時才需要設定其中一個。下方表格是快速摘要,其後的章節則補充每個旗標各自需要的細節。 | 旗標 | 預設值 | 收緊後的值 → 誰仍然可以呼叫 | 典型用途 | | --------------- | ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `agentTool` | `true` | `false` → 僅限 UI、HTTP 與 CLI(對模型、MCP 與 A2A 隱藏) | 不該佔用工具位置的僅限 UI 或程式化用途的 action | +| `mcpTool` | `agentTool` | `false` → 只有你自己應用程式的代理(對 MCP 與 A2A 隱藏) | 需要對端具備應用程式畫面或工作階段的 action | | `toolCallable` | `true` | `false` → 除了沙盒化擴充功能 iframe 橋接以外的一切都仍可呼叫(該橋接會收到 403) | 敏感的帳號或組織變更(刪除帳號、變更成員資格) | | `publicAgent` | 關閉(私有) | `{ expose: true }` → 將此 action 加入**公開**的 MCP/A2A/OpenAPI 介面 | 不需要驗證的安全讀取或匯入工具 | | `needsApproval` | `false` | `true` → 代理會**暫停**,必須由人工核准該次特定呼叫 | 具有重大影響的副作用(傳送電子郵件、刷卡扣款、刪除) | | `authorize` | 無 | 一個守衛函式 → 只有它接受的呼叫者才能通過,適用於**每一種**介面 | 限定角色才能執行的操作(每個應用程式各自的 RBAC、僅限管理員的操作) | -這些旗標彼此獨立,設定其中一個不會影響其他旗標。`agentTool` 控制模型能看到什麼,請參閱下方的[對模型隱藏](#agent-tool)。`toolCallable` 只控制擴充功能 iframe,請參閱下方的[封鎖擴充功能 iframe](#tool-callable)。`publicAgent` 會新增一個選擇加入的公開介面,公開的網頁路由絕不代表工具也會公開曝露,請參閱下方的[曝露給公開代理](#public-agent)。`needsApproval` 會在呼叫已經發出之後、於執行前設下關卡,請參閱下方的[要求人工核准](#needs-approval)。`authorize` 則決定此呼叫者是否完全可以執行此 action,適用於每一條分派路徑,且在進入 `run()` 之前執行,請參閱下方的[依角色限制呼叫者](#authorize)。 +這些旗標彼此獨立,只有一個例外:未宣告的 `mcpTool` 會繼承 `agentTool`。`agentTool` 控制模型能看到什麼,請參閱下方的[對模型隱藏](#agent-tool)。`mcpTool` 會把這一點進一步收斂到只針對外部代理,請參閱下方的[對外部代理隱藏](#mcp-tool)。`toolCallable` 只控制擴充功能 iframe,請參閱下方的[封鎖擴充功能 iframe](#tool-callable)。`publicAgent` 會新增一個選擇加入的公開介面,公開的網頁路由絕不代表工具也會公開曝露,請參閱下方的[曝露給公開代理](#public-agent)。`needsApproval` 會在呼叫已經發出之後、於執行前設下關卡,請參閱下方的[要求人工核准](#needs-approval)。`authorize` 則決定此呼叫者是否完全可以執行此 action,適用於每一條分派路徑,且在進入 `run()` 之前執行,請參閱下方的[依角色限制呼叫者](#authorize)。 ## 對模型隱藏 {#agent-tool} @@ -39,6 +40,44 @@ export default defineAction({ 當您新增一個僅供 UI 使用或純粹程式化用途的 action,或是當 UI 不再使用某個原本仍會曝露給模型的 action 時,可以使用此設定。 +## 對外部代理隱藏 {#mcp-tool} + +`agentTool` 是全有或全無:一個 action 要嘛對所有代理都是工具,要嘛對誰都不是。`mcpTool` 把它一分為二,讓一個 action 對你自己應用程式的代理仍是一般工具,同時永遠不會經由 MCP 與 A2A 抵達 Claude、Cursor 或兄弟應用程式: + +```ts +export default defineAction({ + description: "Open the record inspector beside the current selection.", + mcpTool: false, // needs a live screen on the other end + schema: z.object({ recordId: z.string() }), + run: async ({ recordId }) => { + /* ... */ + }, +}); +``` + +當一個 action 的結果只有搭配你應用程式的 UI、開啟中的工作階段,或呼叫者眼前的目前選取才有意義時,就使用它。`mcpTool` 只能收緊 `agentTool`,不能放寬:宣告了 `agentTool: false` 的 action 在任何地方都仍然隱藏。它管的是對外的工具介面本身,而不是你自己的代理在回答一次 `ask_app` 提問時能做什麼——那次執行屬於你自己的代理,它會保留完整的工具清單。 + +未宣告時,`mcpTool` 會跟隨 `agentTool`,而不是一律取 `true`:把一個 action 對代理隱藏,也就同時對外部代理隱藏了,一個旗標仍然只對應一個決定。 + +相反的值還有另一個用途。預設情況下,外部代理拿到的是一份精選的小型目錄,而不是你的整個 action 登錄表,而 `mcpTool: true` 就是一個 action 宣告自己屬於該目錄的方式: + +```ts +export default defineAction({ + description: "List the databases connected to this workspace.", + mcpTool: true, // served to external agents, not just discoverable + http: { method: "GET" }, + run: async () => { + /* ... */ + }, +}); +``` + +這與外掛的 `mcp: { connectorCatalog: [...] }` 清單是同一份目錄,只是宣告寫在 action 上,而不是寫在一個每次重新命名都要同步的獨立陣列裡。兩種寫法都有效,應用程式可以一次搬移一個 action。沒有列入的部分仍可透過 `tool-search` 找到。 + +把兩個旗標反過來搭配——`agentTool: false` 搭配 `mcpTool: true`——就會得到一個**僅限 MCP** 的 action:外部代理拿得到,你自己應用程式的代理永遠看不到。適合用在外部工具需要、而你自己的代理沒有理由呼叫的匯出、同步或交接。 + +列在目錄中並不等於擁有權限。外部呼叫者仍然必須通過 OAuth scope 檢查與 `externalAgents` 政策;在不經過模型的直接 A2A 路徑上,還要通過下方的 `publicAgent` 選擇加入。 + ## 封鎖擴充功能 iframe {#tool-callable} 擴充功能([沙盒化 iframe 中的 Alpine.js 迷你應用程式](/docs/extensions))會透過 `appAction(name, params)` 呼叫 action,並以檢視者自己的權限、密鑰與 SQL 範圍執行。對於敏感操作來說,預設情況下這信任程度過高。設定 `toolCallable: false` 可以讓擴充功能橋接回傳 403,同時保留此 action 仍可從 UI、代理、CLI、MCP 與 A2A 呼叫: diff --git a/packages/core/docs/content/locales/zh-TW/actions-defining.mdx b/packages/core/docs/content/locales/zh-TW/actions-defining.mdx index 390e0a4e80..1f404eca94 100644 --- a/packages/core/docs/content/locales/zh-TW/actions-defining.mdx +++ b/packages/core/docs/content/locales/zh-TW/actions-defining.mdx @@ -111,6 +111,21 @@ description: "defineAction() 的結構:架構、run,以及 HTTP 設定,還 description: "設為 false 可從所有代理工具清單中隱藏。請參閱存取與授權。", }, + { + name: "mcpTool", + type: "boolean", + optional: true, + default: "agentTool", + description: + "繼承 `agentTool`。設為 false 可對經由 MCP 與 A2A 的外部代理隱藏,設為 true 則宣告它屬於精選目錄。請參閱「存取與授權」。", + }, + { + name: "deferLoading", + type: "boolean", + optional: true, + description: + "設為 false 可讓該 action 留在代理的第一份工具清單裡,設為 true 則透過 tool-search 按需載入。請參閱下方的「讓 action 介面保持精簡」。", + }, { name: "toolCallable", type: "boolean", @@ -256,6 +271,7 @@ POST 是預設值,`http: { method: "GET" }` 會將它變成 GET。React 掛鉤 - 優先使用**一個 CRUD 風格的 `update` action**,接受一組可選欄位的修補內容,而不是像 `update-name`、`update-order`、`update-color` 這樣許多按欄位分開的 action。呼叫者只需要傳送有變更的欄位。 - 在為每個查詢或篩選條件新增一個讀取 action 之前,請先考慮更通用的選項:供應商資料可用[供應商 API 三件組](/docs/template-dispatch-vault-integrations#provider-api)(`provider-api-catalog`、`provider-api-docs` 與 `provider-api-request`),應用程式資料則可用開發用的 `db-query` 工具。 - 將僅供 UI 使用或程式化用途的 action 標記為 [`agentTool: false`](/docs/actions-access-control#agent-tool)。它們仍可從前端或透過 HTTP 呼叫,但不會佔用模型工具清單中的位置。 +- 把代理時時都會用到的少數幾個 action 標記為 `deferLoading: false`。第一次模型請求送出的正是這些 schema;其餘的會在代理需要時透過 `tool-search` 按需載入。只要有一個 action 這樣退出,所有未標記的 action 就都會被延後,所以請一次標完整套起步 action,而不是一次標一個。這關乎第一輪的成本,而不是存取權限:被延後的 action 在 `tool-search` 回傳它的那一刻就可以呼叫。它是外掛 `initialToolNames` 陣列的逐 action 寫法,應用程式兩者擇一即可。 - 刪除或隱藏 UI 已不再使用的 action,而不是繼續讓它們曝露給模型。 此儲存庫包含一個建議性的輔助工具 `node scripts/audit-template-actions.mjs [template ...]`(別名 `pnpm actions:audit`)。它會掃描一個範本的 `actions/` 資料夾,標出 UI 可能已不再使用的 action,以及可以合併成一個的按欄位分開的 action 群組。它一律以代碼 0 結束,永遠不會讓 CI 失敗,而且其判斷方式偏保守,因此請把它的輸出當作建議,而非錯誤。 diff --git a/packages/core/src/action.spec.ts b/packages/core/src/action.spec.ts index 226085bd80..d7f4ccdaf5 100644 --- a/packages/core/src/action.spec.ts +++ b/packages/core/src/action.spec.ts @@ -7,6 +7,8 @@ import { isActionContractError, AgentActionStopError, isAgentActionStopError, + isActionExposedToExternalAgents, + isActionHiddenFromEveryAgentSurface, } from "./action.js"; describe("ActionContractError", () => { @@ -196,6 +198,62 @@ describe("defineAction", () => { expect(action.agentTool).toBeUndefined(); }); + it("threads through mcpTool and deferLoading, and leaves both undefined by default", () => { + const external = defineAction({ + description: "share a plan with an external agent", + parameters: { id: { type: "string" } }, + mcpTool: true, + deferLoading: false, + run: async () => "ok", + }); + expect(external.mcpTool).toBe(true); + expect(external.deferLoading).toBe(false); + + const inAppOnly = defineAction({ + description: "open the inspector panel", + parameters: { id: { type: "string" } }, + mcpTool: false, + deferLoading: true, + run: async () => "ok", + }); + expect(inAppOnly.mcpTool).toBe(false); + expect(inAppOnly.deferLoading).toBe(true); + + // Undefined is a third state both surfaces read — it must not collapse to + // the default value here, or the declaration becomes unreadable. + const plain = defineAction({ + description: "normal action", + parameters: { id: { type: "string" } }, + run: async () => "ok", + }); + expect(plain.mcpTool).toBeUndefined(); + expect(plain.deferLoading).toBeUndefined(); + }); + + it("resolves external exposure from mcpTool, falling back to agentTool", () => { + // Inheritance, not a flat default: one flag stays one decision until an + // action says otherwise. + expect(isActionExposedToExternalAgents({})).toBe(true); + expect(isActionExposedToExternalAgents({ agentTool: false })).toBe(false); + expect(isActionExposedToExternalAgents({ mcpTool: false })).toBe(false); + expect( + isActionExposedToExternalAgents({ agentTool: false, mcpTool: true }), + ).toBe(true); + expect( + isActionExposedToExternalAgents({ agentTool: true, mcpTool: false }), + ).toBe(false); + + // The runtime backstop refuses only what no surface may run, so an + // MCP-only action stays callable through the external registries. + expect(isActionHiddenFromEveryAgentSurface({ agentTool: false })).toBe( + true, + ); + expect( + isActionHiddenFromEveryAgentSurface({ agentTool: false, mcpTool: true }), + ).toBe(false); + expect(isActionHiddenFromEveryAgentSurface({ mcpTool: false })).toBe(false); + }); + it("preserves valid MCP Apps resource metadata", () => { const action = defineAction({ description: "review draft", diff --git a/packages/core/src/action.ts b/packages/core/src/action.ts index cd94959ed4..6e6b8ee01b 100644 --- a/packages/core/src/action.ts +++ b/packages/core/src/action.ts @@ -511,6 +511,49 @@ interface DefineActionWithSchema< * tool list. Distinct from `toolCallable`, which only governs the sandboxed * extension ("tools") iframe bridge. See `packages/core/docs/content/actions.mdx`. */ agentTool?: boolean; + /** Whether this action is exposed to EXTERNAL agents over MCP (and the direct + * A2A action surface, which shares the same policy). + * + * **Defaults to `agentTool`, not to `true`**: an action hidden from the + * agent is hidden from outside agents too, so one flag stays one decision. + * Declaring `mcpTool` overrides that inheritance in both directions. + * + * - `false` — hard veto. The action is absent from `tools/list` AND from + * `tools/call` on every MCP tier, including the rare `--full-catalog` + * opt-in, while the in-app agent still calls it normally. Use it for + * actions that only make sense with an in-app screen, a live session, or + * the framework's own UI on the other end. + * - `true` — the action-owned form of `mcp.connectorCatalog` membership: + * this action is served on the curated external catalog. Declaring it on + * any action activates the connector tier for the app, which is what lets + * a template delete its hand-maintained `connectorCatalog` name list. + * Paired with `agentTool: false` it makes the action MCP-only — external + * agents get it, the app's own agent does not. + * - `undefined` (default) — follows `agentTool`; the tier rules then decide + * whether it is advertised up front or found through `tool-search`. + * + * Membership is not permission: an external caller still passes the OAuth + * scope, `externalAgents` policy, and `publicAgent` checks. Resolved by + * `isActionExposedToExternalAgents` below, which every external surface + * reads instead of testing these two fields itself. */ + mcpTool?: boolean; + /** Whether this action's schema is held back from the agent's FIRST-REQUEST + * tool list and loaded on demand through `tool-search` instead. The + * action-owned form of the plugin's `initialToolNames` array, so the app's + * starter surface is declared beside each action rather than in a list that + * drifts as actions are renamed. + * + * - `false` — always in the initial set. As soon as ANY action declares + * this, the derived default flips from "every one of the app's own + * actions" to "the ones that opted in", which is what makes deleting + * `initialToolNames` a real trim rather than a rename. + * - `true` — never in the DERIVED set. An explicit `initialToolNames` entry + * still wins, so a stale list is never silently overridden. + * - `undefined` (default) — unchanged behavior. + * + * This is about first-turn context cost, not access: a deferred action is + * still callable the moment `tool-search` returns it. */ + deferLoading?: boolean; /** If true, the framework will NOT emit a screen-refresh change event after a * successful call. Auto-inferred as `true` when `http.method === "GET"`. * Only set this manually when you need to override the inference — e.g. a @@ -680,6 +723,15 @@ interface DefineActionWithParams< * explicit `false` hides it from every agent tool list while keeping it * frontend/HTTP-callable. See the schema overload above and actions.md. */ agentTool?: boolean; + /** Whether this action is exposed to external agents over MCP / direct A2A. + * Defaults to `agentTool`. `false` is a hard veto on every MCP tier; `true` + * declares curated connector-catalog membership, and with `agentTool: + * false` makes the action MCP-only. See the schema overload above. */ + mcpTool?: boolean; + /** Whether this action's schema is held back from the agent's first-request + * tool list and loaded through `tool-search` instead. The action-owned form + * of the plugin's `initialToolNames`. See the schema overload above. */ + deferLoading?: boolean; /** If true, the framework will NOT emit a screen-refresh change event after a * successful call. Auto-inferred as `true` when `http.method === "GET"`. */ readOnly?: boolean; @@ -762,6 +814,8 @@ export interface ActionDefinition { readonly requiresAuth?: boolean; readonly maxBodyBytes?: number; readonly agentTool?: boolean; + readonly mcpTool?: boolean; + readonly deferLoading?: boolean; readonly readOnly?: boolean; readonly grounding?: boolean; readonly allowInPlanMode?: boolean; @@ -968,6 +1022,19 @@ export function defineAction(options: any) { // from the agent tool surfaces; undefined is preserved (treated as exposed). const agentTool: boolean | undefined = typeof options.agentTool === "boolean" ? options.agentTool : undefined; + // mcpTool / deferLoading: like `agentTool`, `undefined` is a distinct third + // state and must survive to the entry. Both flags mean something different + // when declared than when omitted — an explicit `mcpTool: true` puts the + // action on the curated external catalog, and an explicit + // `deferLoading: false` narrows the derived first-request set — so + // collapsing undefined to the default here would erase the declaration the + // surfaces read. + const mcpTool: boolean | undefined = + typeof options.mcpTool === "boolean" ? options.mcpTool : undefined; + const deferLoading: boolean | undefined = + typeof options.deferLoading === "boolean" + ? options.deferLoading + : undefined; const parallelSafe: boolean | undefined = typeof options.parallelSafe === "boolean" ? options.parallelSafe @@ -1023,6 +1090,8 @@ export function defineAction(options: any) { ? { maxBodyBytes: options.maxBodyBytes } : {}), ...(typeof agentTool === "boolean" ? { agentTool } : {}), + ...(typeof mcpTool === "boolean" ? { mcpTool } : {}), + ...(typeof deferLoading === "boolean" ? { deferLoading } : {}), ...(typeof readOnly === "boolean" ? { readOnly } : {}), ...(typeof options.grounding === "boolean" ? { grounding: options.grounding } @@ -1069,6 +1138,51 @@ export function defineAction(options: any) { }; } +/** + * Whether an action is exposed to EXTERNAL agents — MCP and the direct A2A + * action surface. The one resolver every surface asks; nothing downstream + * re-derives this from the two raw fields. + * + * `mcpTool` DEFAULTS TO `agentTool` rather than to a flat `true`, so hiding an + * action from the agent hides it from outside agents too and one flag stays + * one decision. Declaring `mcpTool` overrides that inheritance in both + * directions: + * + * agentTool: false → hidden from every agent surface + * agentTool: false, mcpTool: true → MCP-only: external agents get it, the + * app's own agent does not + * mcpTool: false → in-app only, whatever `agentTool` says + * + * The MCP-only combination needs the plugin to route those actions around the + * `filterAgentTools` gate — see `mcpOnlyActions` in `agent-chat-plugin.ts`. + */ +export function isActionExposedToExternalAgents(entry: { + agentTool?: boolean; + mcpTool?: boolean; +}): boolean { + return typeof entry.mcpTool === "boolean" + ? entry.mcpTool + : entry.agentTool !== false; +} + +/** + * Whether an action is hidden from EVERY agent surface — the in-app agent and + * external agents alike. + * + * The runtime backstops (`executeAgentToolCall`, `searchToolRegistry`) use + * this rather than `agentTool === false`: each caller already passes a + * surface-scoped registry, so the backstop's job is to catch an entry that no + * surface should ever run, not to re-litigate which surface this is. Testing + * `agentTool` alone made those two refuse the MCP-only actions their own + * caller had deliberately handed them. + */ +export function isActionHiddenFromEveryAgentSurface(entry: { + agentTool?: boolean; + mcpTool?: boolean; +}): boolean { + return entry.agentTool === false && entry.mcpTool !== true; +} + /** * Wrap an action's run with its `authorize` gate. * diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 18e4d309af..3180300cbf 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -14,6 +14,7 @@ import { parseA2AAgentActivityPart } from "../a2a/activity.js"; import type { Task } from "../a2a/types.js"; import { describeToolParameterSignature, + isActionHiddenFromEveryAgentSurface, isAgentActionStopError, type ActionAutomationContext, type ActionCaller, @@ -701,6 +702,21 @@ export interface ActionEntry { * MCP, A2A, job/trigger runners) while leaving it frontend/HTTP-callable. * Set by `defineAction`'s `agentTool` option. */ agentTool?: boolean; + /** Whether the action is exposed to EXTERNAL agents over MCP and the direct + * A2A action surface. Defaults to `agentTool`. `false` is a hard veto on + * every MCP tier (including the `--full-catalog` opt-in) while the in-app + * agent keeps calling it; `true` declares curated connector-catalog + * membership, and with `agentTool: false` makes the action MCP-only. Read + * it through `isActionExposedToExternalAgents`, never as a raw field. + * Set by `defineAction`'s `mcpTool` option. */ + mcpTool?: boolean; + /** Whether the action's schema is held back from the agent's first-request + * tool list — the action-owned form of the plugin's `initialToolNames`. + * `false` always includes it (and narrows the derived default to the + * actions that opted in); `true` keeps it out of the DERIVED set, reachable + * through `tool-search`. Context cost, not access. Set by `defineAction`'s + * `deferLoading` option. */ + deferLoading?: boolean; /** Explicit opt-in metadata for public agent protocols. Public routes never * imply public tool exposure; MCP/A2A/OpenAPI surfaces must filter for this. */ publicAgent?: import("../action.js").PublicAgentActionConfig; @@ -3299,7 +3315,10 @@ export async function executeAgentToolCall( options: ExecuteAgentToolCallOptions, ): Promise { const entry = options.actions[options.name]; - if (!entry || entry.agentTool === false) { + // The caller's registry is the surface: A2A hands this the external one, so + // reject only what no surface may run — an MCP-only action is `agentTool: + // false` and still legitimately callable here. + if (!entry || isActionHiddenFromEveryAgentSurface(entry)) { return { status: "failed", output: `Unknown or unavailable tool: ${options.name}`, diff --git a/packages/core/src/agent/tool-search.ts b/packages/core/src/agent/tool-search.ts index 128a46f335..475b45e0c9 100644 --- a/packages/core/src/agent/tool-search.ts +++ b/packages/core/src/agent/tool-search.ts @@ -1,3 +1,4 @@ +import { isActionHiddenFromEveryAgentSurface } from "../action.js"; import { parseMcpToolName } from "../mcp-client/manager.js"; import { isMcpToolAllowedForRequest } from "../mcp-client/visibility.js"; import { getRequestRunContext } from "../server/request-context.js"; @@ -156,7 +157,7 @@ export function searchToolRegistry( for (const [name, entry] of Object.entries(registry)) { if (!entry?.tool || name === TOOL_SEARCH_ACTION_NAME) continue; - if (entry.agentTool === false) continue; + if (isActionHiddenFromEveryAgentSurface(entry)) continue; if (name.startsWith("mcp__") && !isMcpToolAllowedForRequest(name)) { continue; } diff --git a/packages/core/src/mcp/build-server.ts b/packages/core/src/mcp/build-server.ts index 0051b4093f..1ed2ea16ab 100644 --- a/packages/core/src/mcp/build-server.ts +++ b/packages/core/src/mcp/build-server.ts @@ -33,6 +33,7 @@ import { type ActionMcpAppCsp, type ActionMcpAppResourceConfig, } from "../action.js"; +import { isActionExposedToExternalAgents } from "../action.js"; import type { ActionEntry } from "../agent/production-agent.js"; import { isMcpActionResult } from "../mcp-client/app-result.js"; import { getConfiguredAppBasePath } from "../server/app-base-path.js"; @@ -426,6 +427,44 @@ function scopeToolSearchToAdvertised( }; } +/** + * Drop every action this request's surface must not reach at all. + * + * Applied to `actions` — the whole surface this request can reach — and not + * just to the advertised listing, because on the `--full-catalog` tier + * `actions` IS the callable set (see the `tools/call` handler below). An + * external-agent opt-out that only hid the schema would leave the action + * callable by name, which is the same "hidden but reachable" bug the + * `withoutToolSearch` comment above exists to prevent. + * + * The in-app agent never comes through here, so its tool list is unaffected. + */ +function withoutExternalOptOuts( + actions: Record, +): Record { + return Object.fromEntries( + Object.entries(actions).filter(([, entry]) => + isActionExposedToExternalAgents(entry), + ), + ); +} + +/** + * Connector-catalog membership declared on the actions themselves. + * + * This is `mcp.connectorCatalog` with the list inverted into the action files, + * so a rename moves the declaration with the action instead of stranding a + * dead string in a plugin config. Both forms feed the same set and an app can + * run either or both while it migrates. + */ +export function declaredMcpToolNames( + actions: Record, +): string[] { + return Object.entries(actions) + .filter(([, entry]) => entry.mcpTool === true) + .map(([name]) => name); +} + const COMPACT_MCP_APP_CATALOG_BUILTINS = new Set([ "list_apps", "open_app", @@ -1649,9 +1688,9 @@ export async function createMCPServerForRequest( // Strip from `actions`, not just the advertised set: on the full-catalog tier // `actions` IS the callable surface, so filtering only the listing would // leave `tool-search` callable but invisible. - const actions = flatCatalog - ? withoutToolSearch(mergedActions) - : mergedActions; + const actions = withoutExternalOptOuts( + flatCatalog ? withoutToolSearch(mergedActions) : mergedActions, + ); const visibleActions = Object.fromEntries( Object.entries(actions).filter(([, entry]) => isActionVisibleForOAuthScope(entry, effectiveIdentity?.oauthScopes), @@ -1674,16 +1713,18 @@ export async function createMCPServerForRequest( const autoReadNames = autoAuthenticatedReadNames(visibleActions, config); const connectorNames = new Set([ ...(config.connectorCatalog ?? []), + ...declaredMcpToolNames(visibleActions), ...autoReadNames, ]); const denyNames = externalAgentDenySet(config); const automaticConnectorPolicyActive = config.externalAgents?.authenticatedReads === "auto"; - // Connector-catalog tier: when a template declares a connector allow-list, - // serve exactly that curated surface plus any explicitly annotated - // authenticated reads from `externalAgents.authenticatedReads: "auto"`. - // This stays compact by default and keeps db-exec / seed-* / extension / - // browser-session footguns off the external surface. + // Connector-catalog tier: when a template declares a connector allow-list — + // as `mcp.connectorCatalog` names or as `mcpTool: true` on the actions + // themselves — serve exactly that curated surface plus any explicitly + // annotated authenticated reads from `externalAgents.authenticatedReads: + // "auto"`. This stays compact by default and keeps db-exec / seed-* / + // extension / browser-session footguns off the external surface. const connectorCatalogActive = !appCatalog && (connectorNames.size > 0 || automaticConnectorPolicyActive) && diff --git a/packages/core/src/mcp/connector-catalog.spec.ts b/packages/core/src/mcp/connector-catalog.spec.ts index d71f2fcf13..d125443a45 100644 --- a/packages/core/src/mcp/connector-catalog.spec.ts +++ b/packages/core/src/mcp/connector-catalog.spec.ts @@ -563,6 +563,191 @@ describe("connector-catalog tier", () => { }); }); + describe("action-declared `mcpTool`", () => { + /** Two actions the config catalog says nothing about: one opts itself in, + * one opts itself out of the external surface entirely. */ + const declaringActions = { + ...(fullActions as Record), + "share-plan-externally": { + tool: { description: "Share a plan with an external agent" }, + mcpTool: true, + run: async () => ({ ok: true }), + }, + "open-plan-inspector": { + tool: { description: "Open the in-app inspector panel" }, + mcpTool: false, + run: async () => ({ ok: true }), + }, + }; + const declaringConfig = { + ...connectorConfig, + actions: declaringActions, + productionActions: declaringActions, + }; + + it("advertises `mcpTool: true` alongside the configured catalog", async () => { + const token = await signA2AToken("alice@example.com"); + const out = await call( + { jsonrpc: "2.0", id: 60, method: "tools/list", params: {} }, + { + headers: { authorization: `Bearer ${token}` }, + mcpConfig: declaringConfig, + }, + ); + const names: string[] = out.result.tools.map((t: any) => t.name); + expect(names).toContain("share-plan-externally"); + for (const tool of CONNECTOR_CATALOG) expect(names).toContain(tool); + expect(names).not.toContain("db-exec"); + }); + + it("activates the connector tier with no configured catalog at all", async () => { + const token = await signA2AToken("alice@example.com"); + const { connectorCatalog: _dropped, ...noCatalogConfig } = + declaringConfig; + const out = await call( + { jsonrpc: "2.0", id: 61, method: "tools/list", params: {} }, + { + headers: { authorization: `Bearer ${token}` }, + mcpConfig: noCatalogConfig, + }, + ); + const names: string[] = out.result.tools.map((t: any) => t.name); + expect(names).toContain("share-plan-externally"); + // The tier is real, not a fallback to "everything": the actions the + // config catalog used to carry are gone with it. + expect(names).not.toContain("create-plan"); + expect(names).not.toContain("db-exec"); + }); + + it("hides `mcpTool: false` from tools/list and tools/call", async () => { + const token = await signA2AToken("alice@example.com"); + const headers = { authorization: `Bearer ${token}` }; + const listed = await call( + { jsonrpc: "2.0", id: 62, method: "tools/list", params: {} }, + { headers, mcpConfig: declaringConfig }, + ); + expect(listed.result.tools.map((t: any) => t.name)).not.toContain( + "open-plan-inspector", + ); + const called = await call( + { + jsonrpc: "2.0", + id: 63, + method: "tools/call", + params: { name: "open-plan-inspector", arguments: {} }, + }, + { headers, mcpConfig: declaringConfig }, + ); + expect(called.result.isError).toBe(true); + expect(called.result.content[0].text).toMatch(/Unknown tool/); + }); + + it("inherits `agentTool: false` when `mcpTool` is not declared", async () => { + const inheriting = { + ...(fullActions as Record), + "sidebar-width": { + tool: { description: "Persist the sidebar width" }, + agentTool: false, + run: async () => ({ ok: true }), + }, + }; + const cfg = { + ...connectorConfig, + actions: inheriting, + productionActions: inheriting, + }; + const token = await signA2AToken("alice@example.com", { + catalog_scope: "full", + }); + const headers = { authorization: `Bearer ${token}` }; + const listed = await call( + { jsonrpc: "2.0", id: 66, method: "tools/list", params: {} }, + { headers, mcpConfig: cfg }, + ); + expect(listed.result.tools.map((t: any) => t.name)).not.toContain( + "sidebar-width", + ); + const called = await call( + { + jsonrpc: "2.0", + id: 67, + method: "tools/call", + params: { name: "sidebar-width", arguments: {} }, + }, + { headers, mcpConfig: cfg }, + ); + expect(called.result.isError).toBe(true); + expect(called.result.content[0].text).toMatch(/Unknown tool/); + }); + + it("serves an MCP-only action: `agentTool: false` with `mcpTool: true`", async () => { + const mcpOnly = { + ...(fullActions as Record), + "export-plan-archive": { + tool: { description: "Export a plan archive for an external agent" }, + agentTool: false, + mcpTool: true, + run: async () => ({ archived: true }), + }, + }; + const cfg = { + ...connectorConfig, + actions: mcpOnly, + productionActions: mcpOnly, + }; + const token = await signA2AToken("alice@example.com"); + const headers = { authorization: `Bearer ${token}` }; + const listed = await call( + { jsonrpc: "2.0", id: 68, method: "tools/list", params: {} }, + { headers, mcpConfig: cfg }, + ); + expect(listed.result.tools.map((t: any) => t.name)).toContain( + "export-plan-archive", + ); + const called = await call( + { + jsonrpc: "2.0", + id: 69, + method: "tools/call", + params: { name: "export-plan-archive", arguments: {} }, + }, + { headers, mcpConfig: cfg }, + ); + expect(called.result.isError).toBeFalsy(); + expect(called.result.content.map((c: any) => c.text).join(" ")).toContain( + "archived", + ); + }); + + it("keeps `mcpTool: false` uncallable on the full-catalog opt-in", async () => { + // The veto has to bite on the tier where `actions` IS the callable + // surface, or "hidden" would only mean "not listed". + const token = await signA2AToken("alice@example.com", { + catalog_scope: "full", + }); + const headers = { authorization: `Bearer ${token}` }; + const listed = await call( + { jsonrpc: "2.0", id: 64, method: "tools/list", params: {} }, + { headers, mcpConfig: declaringConfig }, + ); + const names: string[] = listed.result.tools.map((t: any) => t.name); + expect(names).toContain("db-exec"); + expect(names).not.toContain("open-plan-inspector"); + + const called = await call( + { + jsonrpc: "2.0", + id: 65, + method: "tools/call", + params: { name: "open-plan-inspector", arguments: {} }, + }, + { headers, mcpConfig: declaringConfig }, + ); + expect(called.result.isError).toBe(true); + expect(called.result.content[0].text).toMatch(/Unknown tool/); + }); + }); + describe("per-token opt-up: catalog_scope: 'full' in A2A JWT", () => { it("serves full catalog when catalog_scope: 'full' is in the A2A token", async () => { const token = await signA2AToken("alice@example.com", { diff --git a/packages/core/src/server/action-discovery.ts b/packages/core/src/server/action-discovery.ts index d1e75474a5..9a5794eb41 100644 --- a/packages/core/src/server/action-discovery.ts +++ b/packages/core/src/server/action-discovery.ts @@ -211,6 +211,10 @@ function wrapDefaultExport( function preserveActionFlags(entry: Record): Partial { const out: Partial = {}; if (typeof entry.agentTool === "boolean") out.agentTool = entry.agentTool; + if (typeof entry.mcpTool === "boolean") out.mcpTool = entry.mcpTool; + if (typeof entry.deferLoading === "boolean") { + out.deferLoading = entry.deferLoading; + } if (typeof entry.requiresAuth === "boolean") { out.requiresAuth = entry.requiresAuth; } diff --git a/packages/core/src/server/agent-chat-plugin.ts b/packages/core/src/server/agent-chat-plugin.ts index ca241fa272..398f0ffaf4 100644 --- a/packages/core/src/server/agent-chat-plugin.ts +++ b/packages/core/src/server/agent-chat-plugin.ts @@ -177,6 +177,7 @@ import { getHubStatus, isHubServeEnabled, } from "../mcp-client/index.js"; +import { declaredMcpToolNames } from "../mcp/build-server.js"; import { setProgressPreListHook } from "../progress/store.js"; import { getSkillNameFromPath } from "../resources/metadata.js"; import { @@ -314,6 +315,7 @@ import { DEFAULT_DELEGATED_MAX_RUN_INPUT_TOKENS, DEFAULT_DELEGATED_MAX_TOOL_RESULT_CHARS, filterAgentTools, + filterMcpOnlyActions, filterPublicAgentActions, filterDirectA2AActions, filterReadOnlyActions, @@ -1105,6 +1107,20 @@ export function createAgentChatPlugin( filterAgentTools(discoveredActionsAll), disabledFrameworkGroups, ); + // MCP-only actions: `agentTool: false` with an explicit `mcpTool: true`. + // `filterAgentTools` above just dropped them — correctly, since the app's + // own agent must not see them — so the external surfaces below re-merge + // this set instead of reading the unfiltered registries. Keep it OUT of + // `allScripts`, the chat/A2A/ask_app loops, and every prompt: an + // `ask_app` run is the app's own agent, which `agentTool: false` already + // answered. Only the direct MCP and A2A tool surfaces get these. + const mcpOnlyActions = filterFrameworkToolGroups( + filterMcpOnlyActions({ + ...discoveredActionsAll, + ...templateScriptsAll, + }), + disabledFrameworkGroups, + ); // Per-request owner is read from the AsyncLocalStorage run context // (populated by prepareRun). Module-scope `let` would race across // concurrent requests on a long-lived Node process — overlapping @@ -1617,6 +1633,15 @@ export function createAgentChatPlugin( }) : undefined; + // The surface external agents get: everything the app's agent has, plus + // the MCP-only actions the agent filter removed. The agent sets win on a + // name collision, which by construction cannot happen — an action is in + // one set or the other, never both. + const externalActions = { ...mcpOnlyActions, ...allScripts }; + const externalFullActions = mcpFullActions + ? { ...mcpOnlyActions, ...mcpFullActions } + : undefined; + const { mountA2A } = await import("../a2a/server.js"); mountA2A(nitroApp, { appId: options?.appId, @@ -1624,9 +1649,9 @@ export function createAgentChatPlugin( ? options.appId.charAt(0).toUpperCase() + options.appId.slice(1) : "Agent", description: `Agent-native ${options?.appId ?? "app"} agent`, - skills: buildPublicAgentA2ASkills(allScripts), + skills: buildPublicAgentA2ASkills(externalActions), authenticatedSkills: buildAuthenticatedAgentA2ASkills( - mcpFullActions ?? allScripts, + externalFullActions ?? externalActions, mcpOptions, ), publicSkillsOnly: true, @@ -1634,7 +1659,7 @@ export function createAgentChatPlugin( durableBackgroundRuns: options?.durableBackgroundRuns, executeReadOnlyAction: async ({ action, input, invocationId }) => { const actions = filterDirectA2AActions( - mcpFullActions ?? allScripts, + externalFullActions ?? externalActions, mcpOptions, ); const entry = actions[action]; @@ -1667,7 +1692,7 @@ export function createAgentChatPlugin( }, executeApproval: async (approval) => { const result = await executeAgentToolCall({ - actions: mcpFullActions ?? allScripts, + actions: externalFullActions ?? externalActions, name: approval.tool, input: approval.input, callId: approval.callId, @@ -2041,7 +2066,14 @@ export function createAgentChatPlugin( effectiveInitialToolNames, { receiverOwnsObjective, - localCapabilityNames: mcpOptions.connectorCatalog, + // Same curated set the MCP mount serves: config names plus the + // actions that declare `mcpTool: true` themselves. + localCapabilityNames: [ + ...new Set([ + ...(mcpOptions.connectorCatalog ?? []), + ...declaredMcpToolNames(a2aActions), + ]), + ], }, ); @@ -2455,8 +2487,8 @@ export function createAgentChatPlugin( `Agent-native ${options?.appId ?? "app"} agent`, websiteUrl: mcpOptions.websiteUrl, icons: mcpOptions.icons, - actions: allScripts, - productionActions: mcpFullActions, + actions: externalActions, + productionActions: externalFullActions, ...(mcpOptions.catalog ? { catalogMode: mcpOptions.catalog } : {}), ...(mcpOptions.builtinCrossAppTools !== undefined ? { builtinCrossAppTools: mcpOptions.builtinCrossAppTools } diff --git a/packages/core/src/server/agent-chat/action-filters-a2a.spec.ts b/packages/core/src/server/agent-chat/action-filters-a2a.spec.ts index fcfd5513ac..655bb3f1e9 100644 --- a/packages/core/src/server/agent-chat/action-filters-a2a.spec.ts +++ b/packages/core/src/server/agent-chat/action-filters-a2a.spec.ts @@ -7,6 +7,7 @@ import { buildPublicAgentA2ASkills, filterDelegatedA2ACapabilityActions, filterDirectA2AActions, + filterMcpOnlyActions, resolveInitialToolNames, } from "./action-filters-a2a.js"; @@ -115,6 +116,62 @@ describe("filterDirectA2AActions", () => { ).toEqual(["search-text", "semantic"]); }); + it("reads `mcpTool` as catalog membership and as a veto", () => { + const actions = { + "list-plans": action({ mcpTool: true }), + "get-plan": action(), + "open-inspector": action({ mcpTool: false }), + }; + + // No configured catalog: the action's own `mcpTool: true` selects it. + expect(Object.keys(filterDirectA2AActions(actions, {}))).toEqual([ + "list-plans", + ]); + + // A configured catalog cannot re-open an action that vetoed itself. + expect( + Object.keys( + filterDirectA2AActions(actions, { + connectorCatalog: ["get-plan", "open-inspector"], + }), + ).sort(), + ).toEqual(["get-plan", "list-plans"]); + }); + + it("inherits agentTool when mcpTool is undefined, and lets mcpTool override it", () => { + const actions = { + "hidden-read": action({ agentTool: false }), + "mcp-only-read": action({ agentTool: false, mcpTool: true }), + }; + + // A configured catalog does not resurrect an agent-hidden action... + expect( + Object.keys( + filterDirectA2AActions(actions, { + connectorCatalog: ["hidden-read", "mcp-only-read"], + }), + ), + ).toEqual(["mcp-only-read"]); + + // ...and the MCP-only one needs no catalog entry at all. + expect(Object.keys(filterDirectA2AActions(actions, {}))).toEqual([ + "mcp-only-read", + ]); + }); + + it("collects only the MCP-only actions for the external mounts", () => { + expect( + Object.keys( + filterMcpOnlyActions({ + "mcp-only": action({ agentTool: false, mcpTool: true }), + "agent-hidden": action({ agentTool: false }), + "catalog-member": action({ mcpTool: true }), + normal: action(), + }), + ), + ).toEqual(["mcp-only"]); + }); + it("allows a raw query input only with an explicit opt-in", () => { const actions = { "raw-sql": action({ @@ -294,4 +351,57 @@ describe("resolveInitialToolNames", () => { ]), ).toEqual(["share-resource"]); }); + + it("narrows the derived list to the actions that opted out of deferral", () => { + expect( + resolveInitialToolNames({ + "list-forms": action({ deferLoading: false }), + "create-form": action({ deferLoading: false }), + "export-form-archive": action(), + }), + ).toEqual(["list-forms", "create-form"]); + }); + + it("drops `deferLoading: true` from the derived list", () => { + expect( + resolveInitialToolNames({ + "list-forms": action(), + "export-form-archive": action({ deferLoading: true }), + }), + ).toEqual(["list-forms"]); + }); + + it("adds eager actions to a configured list without duplicating it", () => { + expect( + resolveInitialToolNames( + { + "list-forms": action({ deferLoading: false }), + "create-form": action({ deferLoading: false }), + "export-form-archive": action(), + }, + ["list-forms", "export-form-archive"], + ), + ).toEqual(["list-forms", "export-form-archive", "create-form"]); + }); + + it("keeps a configured name that the action marked deferred", () => { + // The array is the app's explicit, current statement; an annotation must + // not silently delete a name the app still lists. + expect( + resolveInitialToolNames( + { "export-form-archive": action({ deferLoading: true }) }, + ["export-form-archive"], + ), + ).toEqual(["export-form-archive"]); + }); + + it("honors `deferLoading: false` on a framework kit action", () => { + const [kitName] = Object.keys(CORE_ACTION_GROUPS); + expect( + resolveInitialToolNames({ + [kitName]: action({ deferLoading: false }), + "create-form": action(), + }), + ).toEqual([kitName]); + }); }); diff --git a/packages/core/src/server/agent-chat/action-filters-a2a.ts b/packages/core/src/server/agent-chat/action-filters-a2a.ts index af16ea4fce..615a2de458 100644 --- a/packages/core/src/server/agent-chat/action-filters-a2a.ts +++ b/packages/core/src/server/agent-chat/action-filters-a2a.ts @@ -9,6 +9,7 @@ import { } from "../../a2a/artifact-response.js"; import { collectFinalResponseTextFromAgentEvents } from "../../a2a/response-text.js"; import type { AgentSkill } from "../../a2a/types.js"; +import { isActionExposedToExternalAgents } from "../../action.js"; import { resolveMainChatMaxOutputTokens } from "../../agent/engine/output-tokens.js"; import type { EngineTool } from "../../agent/engine/types.js"; import { @@ -57,6 +58,27 @@ export function filterAgentTools( ); } +/** + * The MCP-only actions: `agentTool: false` paired with an explicit + * `mcpTool: true`. + * + * `filterAgentTools` drops these, which is right for every in-app surface and + * wrong for the two external ones. Rather than have the MCP and A2A mounts + * read the unfiltered registry — and re-derive which agent-hidden actions are + * safe to serve — the plugin re-merges exactly this set into those two mounts. + * A caller that wants "everything external" merges this with the agent surface; + * nobody has to remember which raw fields make an action MCP-only. + */ +export function filterMcpOnlyActions( + actions: Record, +): Record { + return Object.fromEntries( + Object.entries(actions).filter( + ([, entry]) => entry.agentTool === false && entry.mcpTool === true, + ), + ); +} + export function filterPublicAgentActions( actions: Record, ): Record { @@ -140,7 +162,10 @@ export function filterDirectA2AActions( return Object.fromEntries( Object.entries(actions).filter(([name, entry]) => { const exposure = entry.publicAgent; + // `mcpTool: true` is catalog membership declared beside the action; the + // exposure resolver below is the veto no config list can re-open. const selected = + entry.mcpTool === true || catalog.has(name) || (autoReads && isAuthenticatedReadAction(entry) && @@ -151,7 +176,7 @@ export function filterDirectA2AActions( selected && rawQueryAllowed && !denied.has(name) && - entry.agentTool !== false && + isActionExposedToExternalAgents(entry) && entry.readOnly === true && exposure?.expose === true && exposure.readOnly === true && @@ -180,7 +205,7 @@ export function filterDelegatedA2ACapabilityActions( const exposure = entry.publicAgent; return ( !denied.has(name) && - entry.agentTool !== false && + isActionExposedToExternalAgents(entry) && entry.readOnly !== true && exposure?.expose === true && exposure.readOnly === false && @@ -689,22 +714,45 @@ The caller already selected this app to own the current objective. Start with th } /** - * The first-request tool catalog: an explicit `initialToolNames` verbatim, or - * the app's OWN actions by default. + * The first-request tool catalog, in precedence order: + * + * 1. An explicit `initialToolNames`, plus every action that declares + * `deferLoading: false`. The configured list stays authoritative — an + * action it names is never dropped by a `deferLoading: true` — so a + * template can annotate its actions before deleting the array, not after. + * 2. Otherwise, the actions that declared `deferLoading: false`, if any did. + * 3. Otherwise, the app's OWN actions, minus any marked `deferLoading: true`. + * + * Step 2 is what makes `deferLoading` a replacement for the array rather than + * a second copy of it: opting a few starter actions out of deferral on an app + * with no configured list defers everything else, instead of leaving the whole + * registry in and the annotation decorative. * * "Its own actions" excludes the framework kits. They arrive in this same * registry through `autoDiscoverActions` -> `mergeCoreSharingActions`, so the * plain `Object.keys` default promoted ~45 sharing/review/history/flag schemas * into every app's first request whether or not the app had those surfaces. They * remain in `availableTools` and are still found by `tool-search`; an app that - * wants one on turn one names it in `initialToolNames`. + * wants one on turn one sets `deferLoading: false` or names it in + * `initialToolNames`. */ export function resolveInitialToolNames( templateActions: Record, configured?: string[], ): string[] { - if (configured) return configured; - return Object.entries(templateActions) - .filter(([name, entry]) => !isFrameworkGroupedAction(name, entry)) + const entries = Object.entries(templateActions); + // A framework-grouped action stays out of the DERIVED set, but an explicit + // `deferLoading: false` on one is a declaration, not a leak: it is the same + // opt-in `initialToolNames` already gave apps for these names. + const eager = entries + .filter(([, entry]) => entry.deferLoading === false) + .map(([name]) => name); + if (configured) return [...new Set([...configured, ...eager])]; + if (eager.length > 0) return eager; + return entries + .filter( + ([name, entry]) => + !isFrameworkGroupedAction(name, entry) && entry.deferLoading !== true, + ) .map(([name]) => name); }