Create an ARC-1 extension
Guides a developer through building an ARC-1 extension — a local plugin that adds Custom_*
tools to an ARC-1 instance without forking, reusing ARC-1's authenticated SAP client, the
7-scope + allow* safety ceiling, audit, and PP. Encodes the learnings from building the framework
(PR1–PR5) and verifying it live on S/4HANA.
Ground truth — read these first, mirror them:
- User guide (point the developer here):
docs_page/extensions.md
— the canonical how-to (tiers, ctx.http/ctx.run, security, CF/Docker deployment). Published at
the docs site under Using ARC-1 → Extensions (Custom Tools).
- Spec:
docs/research/2026-06-17-extension-framework-spec.md (v1) + extension-framework-v2-spec.md (what's deferred).
- Worked sample:
arc-mcp/arc-1-extension-sample
— ADT + OData reads, a manifest tool, Custom_RunClass (gated execute), an OData
write (Custom_CreateSalesOrder), and a full LISA
custom-ICF integration (Custom_ListLanguages/GetTranslation/SetTranslation) — all
live-verified on S/4HANA (real HTTP 201/200 writes). Copy the closest tool and adapt.
v1 reality (do not get this wrong): reads are open (ctx.http.get/head). Writes (ctx.http.post/
put/delete) work only to non-ADT paths (OData/ICF) behind the opt-in SAP_ALLOW_PLUGIN_RAW_WRITES.
A console class runs via ctx.run.classRun (opt-in SAP_ALLOW_PLUGIN_EXECUTE). ADT object writes
(CLAS/DDLS/… via /sap/bc/adt/…) are always refused — those are the v2 package-aware ctx.write.
Trigger
- "add a custom tool / plugin / extension to ARC-1"
- "wrap this SAP/ADT/OData endpoint as an MCP tool"
- "build my own ARC-1 tool without forking"
- "diagnostic tool on top of ARC-1" (SM37/SLG1/gateway logs, etc.)
Step 1 — decide the path (ask, don't assume)
Use AskUserQuestion. The first question is a gate:
- Backend. Does the tool talk to the same SAP system ARC-1 connects to, over HTTP (ADT,
OData, or a custom ICF/REST service)?
- No — a different SAP product (Cloud ALM, BTP services, BW, HANA, Datasphere, SuccessFactors)
or a non-HTTP protocol (native RFC, SAP GUI scripting) → this is NOT an extension. It is a
separate MCP server (build on the BTP-auth module, the "own-server" path). Stop here and
point them there.
- Yes → continue.
- Tier.
- Manifest tier (declarative JSON, no code) — if the tool is "validate inputs → one read
GET → return". No logic, no writes.
- Code tier (
defineTool, TypeScript) — if it needs logic, response shaping, multiple reads,
a write to an OData/ICF service (ctx.http.post/put/delete), or to execute a console
class (ctx.run.classRun).
- SAP API — ADT (
/sap/bc/adt/…), OData (/sap/opu/odata/…), or a custom ICF (/sap/bc/http/…).
For a custom endpoint: it must already exist on SAP — extensions ship no ABAP.
- What it does, and the scope + opType:
- read (any of the three APIs) →
scope: 'read', opType: OperationType.Read — uses ctx.http.get.
- write to an OData/ICF service (
ctx.http.post/put/delete) → scope: 'write', opType
Create/Update/Delete. Refused unless the admin sets SAP_ALLOW_PLUGIN_RAW_WRITES=true +
SAP_ALLOW_WRITES=true. The path must be non-ADT (/sap/opu/odata/… or /sap/bc/http/…).
- execute a console class (
IF_OO_ADT_CLASSRUN) → scope: 'write', opType: OperationType.Workflow
— uses ctx.run.classRun. Refused unless SAP_ALLOW_PLUGIN_EXECUTE=true + SAP_ALLOW_WRITES=true.
- ADT object create/update/delete (CLAS/DDLS/… via
/sap/bc/adt/…) → NOT available in v1 —
always refused; that's the v2 package-aware ctx.write. If the tool needs it, say so and stop.
Step 2 — scaffold (mirror arc-1-extension-sample)
Create a new repo arc1-plugin-<name> (pure TS, no ABAP):
package.json — "type":"module", peerDep "arc-1": ">=<ver>", devDeps typescript+zod,
build "tsc && node -e \"require('node:fs').cpSync('manifests','dist/manifests',{recursive:true})\""
(only if it has manifests). An optional "arc1": { "apiVersion": 1 } block is a forward
declaration — in v1 the loader reads apiVersion from the Plugin default export (src/index.ts),
and requires:{scopes,packages} is v2 (declared-but-not-yet-enforced), so don't rely on it.
- Read (code tier) →
src/tools/Custom_<X>.ts:import { z } from 'zod';
import { defineTool, OperationType } from 'arc-1/public';
export default defineTool({
name: 'Custom_<X>', // MUST start with Custom_
description: '…',
schema: z.object({ /* … */ }),
policy: { scope: 'read', opType: OperationType.Read },
async handler(args, ctx) {
const res = await ctx.http.get(`/sap/bc/adt/…`, { Accept: 'text/plain' });
return { content: [{ type: 'text', text: /* shape res.body */ }] };
},
});
- Manifest tier →
manifests/Custom_<X>.tool.json:{ "name": "Custom_<X>", "description": "…", "scope": "read",
"inputSchema": { "type": "object", "additionalProperties": false,
"required": ["name"], "properties": { "name": { "type": "string", "pattern": "^[A-Za-z0-9_/]{1,40}$" } } },
"request": { "method": "GET", "path": "/sap/bc/adt/…/{name}/source/main",
"pathParams": { "name": "$.name" }, "accept": "text/plain" },
"response": { "maxBytes": 50000 } }
- Write (code tier) — OData / custom-ICF
POST/PUT/DELETE → src/tools/Custom_<X>.ts:import { z } from 'zod';
import { defineTool, OperationType } from 'arc-1/public';
export default defineTool({
name: 'Custom_<X>',
description: 'Create something via an OData/ICF service.',
schema: z.object({ /* … */ }),
policy: { scope: 'write', opType: OperationType.Create }, // POST→Create / PUT→Update / DELETE→Delete
async handler(args, ctx) {
const body = JSON.stringify(/* entity / payload */);
// path MUST be non-ADT (OData/ICF); CSRF is fetched + attached automatically.
const res = await ctx.http.post('/sap/opu/odata/<ns>/<SERVICE>/<EntitySet>', body, 'application/json',
{ Accept: 'application/json' });
return { content: [{ type: 'text', text: `HTTP ${res.statusCode}\n${res.body}` }] };
},
});
- Execute (code tier) — run a console class →
src/tools/Custom_<X>.ts:import { z } from 'zod';
import { defineTool, OperationType } from 'arc-1/public';
export default defineTool({
name: 'Custom_<X>',
description: 'Execute an ABAP console class and return its output.',
schema: z.object({ className: z.string().min(1).max(40) }),
policy: { scope: 'write', opType: OperationType.Workflow }, // execute ⇒ write-class op
async handler(args, ctx) {
const out = await ctx.run.classRun((args as { className: string }).className); // gated; see Step 1.4
return { content: [{ type: 'text', text: out }] };
},
});
src/index.ts — export default { name, version, apiVersion: 1, tools: [...], manifests: ['manifests/Custom_<X>.tool.json'] } satisfies Plugin;
- README — what it does + the load command.
Step 3 — build + load + test (this is live-verified)
# until arc-1 is published with the public API, link the local build:
( cd /path/to/arc-1 && npm link )
npm install && npm link arc-1 && npm run build
# load into an instance…
ARC1_PLUGINS=$PWD/dist/index.js arc1 --transport http-streamable
# …or drive one read call (args MUST be --json, not positional):
ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_<X> --json '{"name":"RSPARAM"}'
# …a WRITE tool (OData/ICF) needs the raw-write opt-ins (else it's refused):
SAP_ALLOW_PLUGIN_RAW_WRITES=true SAP_ALLOW_WRITES=true \
ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_<X> --json '{ … }'
# …an EXECUTE tool needs the execute opt-ins:
SAP_ALLOW_PLUGIN_EXECUTE=true SAP_ALLOW_WRITES=true \
ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_<X> --json '{"className":"ZCL_FOO"}'
Read the result, not just the exit. A gate refusal is an AdtSafetyError ("…disabled" / "may not
write to an ADT path"); a SAP-side problem (wrong path, missing service, bad payload) is an
AdtApiError with the SAP status + body — that means the gate passed and the request reached SAP
(useful signal). Iterate on the path/payload from the SAP error.
Confirm the tool appears in tools/list and the call returns real SAP data. For deploying the
plugin to BTP Cloud Foundry or Docker (the owner-check / --chown gotcha, image vs buildpack vs
volume trade-offs), point the developer at the Deploying extensions section of
docs_page/extensions.md.
Gotchas (learned the hard way)
Custom_ namespace is mandatory and collisions fail server start (fail-fast).
ctx.http reads freely (GET/HEAD); writes (post/put/delete) hit only NON-ADT paths and
only when the admin sets SAP_ALLOW_PLUGIN_RAW_WRITES=true (+ SAP_ALLOW_WRITES=true) and the tool
declares scope:'write'. Writes to /sap/bc/adt/… object paths are always refused (package
allowlist can't be enforced on a raw write — ADT object writes are the v2 ctx.write vocabulary).
CSRF is fetched + attached automatically. Use this for OData / custom-ICF write services.
ctx.client is a runtime plain-read view — .http/.safety AND the data/SQL reads
(getTableContents/runQuery/runTableQuery) are blocked at runtime (a cast yields undefined).
v1 plugins have no data/SQL surface; use the plain read methods (or ctx.http.get).
policy.opType must match scope — the declared scope has to cover the opType's required scope
(e.g. opType:'U' needs scope:'write'), or the plugin fails server start. Keep them consistent
with the examples above.
- Executing a class is the one privileged op.
ctx.run.classRun(name) runs an IF_OO_ADT_CLASSRUN
console class. Gated: needs SAP_ALLOW_PLUGIN_EXECUTE=true and SAP_ALLOW_WRITES=true and a
write-scoped tool; the class name is validated (no path injection). Off by default.
- OData path discovery — a 403
/IWFND/MED/170 "No service found" usually means the WRONG path,
not just an inactive service. The service name AND namespace matter: e.g. the EPM demo is
/sap/opu/odata/iwbep/GWSAMPLE_BASIC, not /sap/opu/odata/sap/ZGWSAMPLE_BASIC. Find the real
path by GET …/$metadata (200 = right; 403 = wrong path or genuinely inactive → /IWFND/MAINT_SERVICE).
- OData V2 create gotcha: a
POST must not carry a $format=json query option (it's a
SystemQueryOption → 400 "not allowed for this Request Type"). Negotiate JSON via the Accept
header instead. Required entity fields vary — GET …/<EntitySet>?$top=1 to see the shape.
- Custom-ICF (LISA-style) services: typically
POST /sap/bc/http/sap/<SERVICE>/<action> with a
JSON body (the action is in the URL path; the body is the params). Two consequences: (1) if the
service uses POST for reads too, those read tools STILL need SAP_ALLOW_PLUGIN_RAW_WRITES +
scope:'write' (ctx.http gates by HTTP method) — declare opType: Read to keep the operation
honest; (2) a write may require an open transport request — create one with SAPTransport
(on a system with no STMS routes it's a local request, which is fine).
- A write reaching SAP ≠ a 2xx. The gate + CSRF + POST can all succeed and SAP still returns a
4xx (bad payload, inactive service, missing transport). That's an
AdtApiError, not a gate
refusal — adjust the request, the framework did its job.
- Manifest tier = read-only GET,
additionalProperties:false required, path is a template with
no host, path params percent-encoded (traversal-safe). No POST/body in v1.
availableOn: 'onprem' | 'btp' (optional, default all) hides the tool from tools/list when
the resolved system type differs. Hyperfocused mode shows no plugin tools at all.
elicit/notify/sampling on ctx are capability-gated — present only when the MCP client
supports them (absent on the CLI/stdio path).
- Unit-test the handler with
createMockToolContext from arc-1/public/testing (records
ctx.http/ctx.run.classRun calls, returns configured output — no live SAP needed).
- Admin kill switch:
SAP_DENY_ACTIONS=Custom_* (all) or Custom_Foo (one) removes plugin tools.
Deploy (when they ask)
Point at Deploying extensions in docs_page/extensions.md.
Key facts: plugins are local files loaded from an absolute ARC1_PLUGINS path (no $HOME
expansion); on BTP CF use a derived Docker image (FROM ghcr.io/arc-mcp/arc-1, COPY --chown=arc1:arc1
— a plain COPY lands as root and the loader rejects non-owner / world-writable files) or
co-deploy the built dist/ in the buildpack app bits (/home/vcap/app/..., vcap-owned). No
hot-reload (redeploy to change). No XSUAA change to add a plugin (scopes are reused).
1---2name: create-arc1-extension3description: Use when a developer wants to add their own custom tool(s) to an ARC-1 MCP instance — an "extension" or "plugin" (FEAT-61). Guides the key architecture decisions (extension vs separate server; code tier vs manifest tier; which SAP API; scope/opType), then scaffolds the plugin and walks build + load + test. Do NOT use for adding a tool to ARC-1 core itself (that is an in-tree change), or for a different SAP backend (that is a separate server).4---56# Create an ARC-1 extension78Guides a developer through building an **ARC-1 extension** — a local plugin that adds `Custom_*`9tools to an ARC-1 instance **without forking**, reusing ARC-1's authenticated SAP client, the107-scope + allow\* safety ceiling, audit, and PP. Encodes the learnings from building the framework11(PR1–PR5) and verifying it live on S/4HANA.1213**Ground truth — read these first, mirror them:**14- **User guide (point the developer here):** [`docs_page/extensions.md`](../../../docs_page/extensions.md)15 — the canonical how-to (tiers, `ctx.http`/`ctx.run`, security, **CF/Docker deployment**). Published at16 the docs site under *Using ARC-1 → Extensions (Custom Tools)*.17- **Spec:** `docs/research/2026-06-17-extension-framework-spec.md` (v1) + `extension-framework-v2-spec.md` (what's deferred).18- **Worked sample:** [`arc-mcp/arc-1-extension-sample`](https://github.com/arc-mcp/arc-1-extension-sample)19 — ADT + OData **reads**, a **manifest** tool, **`Custom_RunClass`** (gated execute), an OData20 **write** (`Custom_CreateSalesOrder`), and a full **[LISA](https://github.com/ClementRingot/LISA)21 custom-ICF integration** (`Custom_ListLanguages`/`GetTranslation`/`SetTranslation`) — all22 live-verified on S/4HANA (real HTTP 201/200 writes). Copy the closest tool and adapt.2324**v1 reality (do not get this wrong):** reads are open (`ctx.http.get`/`head`). **Writes** (`ctx.http.post`/25`put`/`delete`) work **only to non-ADT paths** (OData/ICF) behind the opt-in `SAP_ALLOW_PLUGIN_RAW_WRITES`.26A console class runs via `ctx.run.classRun` (opt-in `SAP_ALLOW_PLUGIN_EXECUTE`). **ADT object** writes27(CLAS/DDLS/… via `/sap/bc/adt/…`) are **always refused** — those are the v2 package-aware `ctx.write`.2829## Trigger3031- "add a custom tool / plugin / extension to ARC-1"32- "wrap this SAP/ADT/OData endpoint as an MCP tool"33- "build my own ARC-1 tool without forking"34- "diagnostic tool on top of ARC-1" (SM37/SLG1/gateway logs, etc.)3536## Step 1 — decide the path (ask, don't assume)3738Use `AskUserQuestion`. The first question is a gate:39401. **Backend.** Does the tool talk to the **same SAP system ARC-1 connects to, over HTTP** (ADT,41 OData, or a custom ICF/REST service)?42 - **No — a different SAP product** (Cloud ALM, BTP services, BW, HANA, Datasphere, SuccessFactors)43 **or a non-HTTP protocol** (native RFC, SAP GUI scripting) → **this is NOT an extension.** It is a44 **separate MCP server** (build on the BTP-auth module, the "own-server" path). **Stop here** and45 point them there.46 - **Yes** → continue.472. **Tier.**48 - **Manifest tier** (declarative JSON, no code) — if the tool is "validate inputs → one **read**49 GET → return". No logic, no writes.50 - **Code tier** (`defineTool`, TypeScript) — if it needs logic, response shaping, multiple reads,51 a **write** to an OData/ICF service (`ctx.http.post`/`put`/`delete`), or to **execute a console52 class** (`ctx.run.classRun`).533. **SAP API** — ADT (`/sap/bc/adt/…`), OData (`/sap/opu/odata/…`), or a custom ICF (`/sap/bc/http/…`).54 For a custom endpoint: it **must already exist on SAP** — extensions ship **no ABAP**.554. **What it does**, and the **scope** + **opType**:56 - read (any of the three APIs) → `scope: 'read'`, `opType: OperationType.Read` — uses `ctx.http.get`.57 - **write to an OData/ICF service** (`ctx.http.post`/`put`/`delete`) → `scope: 'write'`, `opType`58 `Create`/`Update`/`Delete`. Refused unless the admin sets **`SAP_ALLOW_PLUGIN_RAW_WRITES=true` +59 `SAP_ALLOW_WRITES=true`**. The path must be **non-ADT** (`/sap/opu/odata/…` or `/sap/bc/http/…`).60 - **execute a console class** (`IF_OO_ADT_CLASSRUN`) → `scope: 'write'`, `opType: OperationType.Workflow`61 — uses `ctx.run.classRun`. Refused unless **`SAP_ALLOW_PLUGIN_EXECUTE=true` + `SAP_ALLOW_WRITES=true`**.62 - **ADT object create/update/delete** (CLAS/DDLS/… via `/sap/bc/adt/…`) → **NOT available in v1** —63 always refused; that's the v2 package-aware `ctx.write`. If the tool needs it, say so and stop.6465## Step 2 — scaffold (mirror `arc-1-extension-sample`)6667Create a new repo `arc1-plugin-<name>` (pure TS, **no ABAP**):6869- **`package.json`** — `"type":"module"`, peerDep `"arc-1": ">=<ver>"`, devDeps `typescript`+`zod`,70 build `"tsc && node -e \"require('node:fs').cpSync('manifests','dist/manifests',{recursive:true})\""`71 (only if it has manifests). An optional `"arc1": { "apiVersion": 1 }` block is a **forward72 declaration** — in v1 the loader reads `apiVersion` from the `Plugin` **default export** (`src/index.ts`),73 and `requires:{scopes,packages}` is **v2** (declared-but-not-yet-enforced), so don't rely on it.74- **Read (code tier)** → `src/tools/Custom_<X>.ts`:75 ```ts76 import { z } from 'zod';77 import { defineTool, OperationType } from 'arc-1/public';78 export default defineTool({79 name: 'Custom_<X>', // MUST start with Custom_80 description: '…',81 schema: z.object({ /* … */ }),82 policy: { scope: 'read', opType: OperationType.Read },83 async handler(args, ctx) {84 const res = await ctx.http.get(`/sap/bc/adt/…`, { Accept: 'text/plain' });85 return { content: [{ type: 'text', text: /* shape res.body */ }] };86 },87 });88 ```89- **Manifest tier** → `manifests/Custom_<X>.tool.json`:90 ```json91 { "name": "Custom_<X>", "description": "…", "scope": "read",92 "inputSchema": { "type": "object", "additionalProperties": false,93 "required": ["name"], "properties": { "name": { "type": "string", "pattern": "^[A-Za-z0-9_/]{1,40}$" } } },94 "request": { "method": "GET", "path": "/sap/bc/adt/…/{name}/source/main",95 "pathParams": { "name": "$.name" }, "accept": "text/plain" },96 "response": { "maxBytes": 50000 } }97 ```98- **Write (code tier)** — OData / custom-ICF `POST`/`PUT`/`DELETE` → `src/tools/Custom_<X>.ts`:99 ```ts100 import { z } from 'zod';101 import { defineTool, OperationType } from 'arc-1/public';102 export default defineTool({103 name: 'Custom_<X>',104 description: 'Create something via an OData/ICF service.',105 schema: z.object({ /* … */ }),106 policy: { scope: 'write', opType: OperationType.Create }, // POST→Create / PUT→Update / DELETE→Delete107 async handler(args, ctx) {108 const body = JSON.stringify(/* entity / payload */);109 // path MUST be non-ADT (OData/ICF); CSRF is fetched + attached automatically.110 const res = await ctx.http.post('/sap/opu/odata/<ns>/<SERVICE>/<EntitySet>', body, 'application/json',111 { Accept: 'application/json' });112 return { content: [{ type: 'text', text: `HTTP ${res.statusCode}\n${res.body}` }] };113 },114 });115 ```116- **Execute (code tier)** — run a console class → `src/tools/Custom_<X>.ts`:117 ```ts118 import { z } from 'zod';119 import { defineTool, OperationType } from 'arc-1/public';120 export default defineTool({121 name: 'Custom_<X>',122 description: 'Execute an ABAP console class and return its output.',123 schema: z.object({ className: z.string().min(1).max(40) }),124 policy: { scope: 'write', opType: OperationType.Workflow }, // execute ⇒ write-class op125 async handler(args, ctx) {126 const out = await ctx.run.classRun((args as { className: string }).className); // gated; see Step 1.4127 return { content: [{ type: 'text', text: out }] };128 },129 });130 ```131- **`src/index.ts`** — `export default { name, version, apiVersion: 1, tools: [...], manifests: ['manifests/Custom_<X>.tool.json'] } satisfies Plugin;`132- **README** — what it does + the load command.133134## Step 3 — build + load + test (this is live-verified)135136```sh137# until arc-1 is published with the public API, link the local build:138( cd /path/to/arc-1 && npm link )139npm install && npm link arc-1 && npm run build140141# load into an instance…142ARC1_PLUGINS=$PWD/dist/index.js arc1 --transport http-streamable143# …or drive one read call (args MUST be --json, not positional):144ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_<X> --json '{"name":"RSPARAM"}'145# …a WRITE tool (OData/ICF) needs the raw-write opt-ins (else it's refused):146SAP_ALLOW_PLUGIN_RAW_WRITES=true SAP_ALLOW_WRITES=true \147 ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_<X> --json '{ … }'148# …an EXECUTE tool needs the execute opt-ins:149SAP_ALLOW_PLUGIN_EXECUTE=true SAP_ALLOW_WRITES=true \150 ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_<X> --json '{"className":"ZCL_FOO"}'151```152153Read the result, not just the exit. A gate refusal is an **`AdtSafetyError`** ("…disabled" / "may not154write to an ADT path"); a SAP-side problem (wrong path, missing service, bad payload) is an155**`AdtApiError`** with the SAP status + body — that means the gate *passed* and the request reached SAP156(useful signal). Iterate on the path/payload from the SAP error.157158Confirm the tool appears in `tools/list` and the call returns real SAP data. For **deploying** the159plugin to BTP Cloud Foundry or Docker (the owner-check / `--chown` gotcha, image vs buildpack vs160volume trade-offs), point the developer at the **Deploying extensions** section of161[`docs_page/extensions.md`](../../../docs_page/extensions.md).162163## Gotchas (learned the hard way)164165- **`Custom_` namespace is mandatory** and collisions **fail server start** (fail-fast).166- **`ctx.http` reads freely (GET/HEAD); writes (`post`/`put`/`delete`) hit only NON-ADT paths** and167 only when the admin sets `SAP_ALLOW_PLUGIN_RAW_WRITES=true` (+ `SAP_ALLOW_WRITES=true`) and the tool168 declares `scope:'write'`. Writes to `/sap/bc/adt/…` object paths are **always refused** (package169 allowlist can't be enforced on a raw write — ADT object writes are the v2 `ctx.write` vocabulary).170 CSRF is fetched + attached automatically. Use this for OData / custom-ICF write services.171- **`ctx.client` is a runtime *plain-read* view** — `.http`/`.safety` AND the data/SQL reads172 (`getTableContents`/`runQuery`/`runTableQuery`) are blocked at runtime (a cast yields `undefined`).173 v1 plugins have no data/SQL surface; use the plain read methods (or `ctx.http.get`).174- **`policy.opType` must match `scope`** — the declared scope has to cover the opType's required scope175 (e.g. `opType:'U'` needs `scope:'write'`), or the plugin **fails server start**. Keep them consistent176 with the examples above.177- **Executing a class is the one privileged op.** `ctx.run.classRun(name)` runs an `IF_OO_ADT_CLASSRUN`178 console class. Gated: needs `SAP_ALLOW_PLUGIN_EXECUTE=true` **and** `SAP_ALLOW_WRITES=true` **and** a179 `write`-scoped tool; the class name is validated (no path injection). Off by default.180- **OData path discovery — a 403 `/IWFND/MED/170 "No service found"` usually means the WRONG path,181 not just an inactive service.** The service name AND namespace matter: e.g. the EPM demo is182 `/sap/opu/odata/iwbep/GWSAMPLE_BASIC`, *not* `/sap/opu/odata/sap/ZGWSAMPLE_BASIC`. Find the real183 path by `GET …/$metadata` (200 = right; 403 = wrong path or genuinely inactive → `/IWFND/MAINT_SERVICE`).184- **OData V2 *create* gotcha:** a `POST` must **not** carry a `$format=json` query option (it's a185 SystemQueryOption → `400 "not allowed for this Request Type"`). Negotiate JSON via the `Accept`186 header instead. Required entity fields vary — `GET …/<EntitySet>?$top=1` to see the shape.187- **Custom-ICF (LISA-style) services:** typically `POST /sap/bc/http/sap/<SERVICE>/<action>` with a188 **JSON body** (the action is in the URL path; the body is the params). Two consequences: (1) if the189 service uses `POST` for *reads* too, those read tools STILL need `SAP_ALLOW_PLUGIN_RAW_WRITES` +190 `scope:'write'` (`ctx.http` gates by HTTP method) — declare `opType: Read` to keep the operation191 honest; (2) a write may require an **open transport request** — create one with `SAPTransport`192 (on a system with no STMS routes it's a local request, which is fine).193- **A write reaching SAP ≠ a 2xx.** The gate + CSRF + POST can all succeed and SAP still returns a194 4xx (bad payload, inactive service, missing transport). That's an `AdtApiError`, not a gate195 refusal — adjust the request, the framework did its job.196- **Manifest tier = read-only GET**, `additionalProperties:false` required, `path` is a template with197 **no host**, path params percent-encoded (traversal-safe). No POST/body in v1.198- **`availableOn: 'onprem' | 'btp'`** (optional, default `all`) hides the tool from `tools/list` when199 the resolved system type differs. Hyperfocused mode shows no plugin tools at all.200- **`elicit`/`notify`/`sampling`** on `ctx` are **capability-gated** — present only when the MCP client201 supports them (absent on the CLI/stdio path).202- **Unit-test the handler** with `createMockToolContext` from `arc-1/public/testing` (records203 `ctx.http`/`ctx.run.classRun` calls, returns configured output — no live SAP needed).204- **Admin kill switch:** `SAP_DENY_ACTIONS=Custom_*` (all) or `Custom_Foo` (one) removes plugin tools.205206## Deploy (when they ask)207208Point at **Deploying extensions** in [`docs_page/extensions.md`](../../../docs_page/extensions.md).209Key facts: plugins are **local files** loaded from an **absolute** `ARC1_PLUGINS` path (no `$HOME`210expansion); on BTP CF use a **derived Docker image** (`FROM ghcr.io/arc-mcp/arc-1`, `COPY --chown=arc1:arc1`211— a plain `COPY` lands as root and the loader **rejects non-owner / world-writable** files) **or**212co-deploy the built `dist/` in the buildpack app bits (`/home/vcap/app/...`, `vcap`-owned). **No213hot-reload** (redeploy to change). **No XSUAA change** to add a plugin (scopes are reused).