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_RunClassandCustom_RunReport(gated named execution), 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.
Console classes and classic reports run via ctx.run.classRun / ctx.run.programRun (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 or classic report (ctx.run.classRun/ctx.run.programRun).
- 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— usesctx.http.get. - write to an OData/ICF service (
ctx.http.post/put/delete) →scope: 'write',opTypeCreate/Update/Delete. Refused unless the admin setsSAP_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— usesctx.run.classRun. Refused unlessSAP_ALLOW_PLUGIN_EXECUTE=true+SAP_ALLOW_WRITES=true. - execute a classic report (
PROG, on-premise) →scope: 'write',opType: OperationType.Workflow,availableOn: 'onprem'— usesctx.run.programRun. It is name-in/text-out: no selection parameters or variants. Uses the same execute + write gates. - ADT object create/update/delete (CLAS/DDLS/… via
/sap/bc/adt/…) → NOT available in v1 — always refused; that's the v2 package-awarectx.write. If the tool needs it, say so and stop.
- read (any of the three APIs) →
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>", devDepstypescript+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 readsapiVersionfrom thePlugindefault export (src/index.ts), andrequires:{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 or classic report →
src/tools/Custom_<X>.ts. Console class example:
Classic report example: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 }] }; }, });import { z } from 'zod'; import { defineTool, OperationType } from 'arc-1/public'; export default defineTool({ name: 'Custom_<X>', description: 'Execute a classic ABAP report and return SAP list or error text.', schema: z.object({ reportName: z.string().min(1).max(40) }), policy: { scope: 'write', opType: OperationType.Workflow }, availableOn: 'onprem', async handler(args, ctx) { const out = await ctx.run.programRun((args as { reportName: string }).reportName); return { content: [{ type: 'text', text: out }] }; }, });programRunaccepts only the report name. If the report needs runtime input, use a smallIF_OO_ADT_CLASSRUNclass. Both named operations return SAP response text verbatim; SAP can put missing-object or execution errors in an HTTP 200 response. 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"}'
# …or, for an on-premise classic report:
SAP_ALLOW_PLUGIN_EXECUTE=true SAP_ALLOW_WRITES=true \
ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_<X> --json '{"reportName":"ZREPORT"}'
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). Named class/report execution is the exception: SAP can put execution errors in an
HTTP 200 text response, which ctx.run returns verbatim. Iterate on the path/payload from the SAP
error; do not infer success from HTTP status alone.
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.httpreads freely (GET/HEAD); writes (post/put/delete) hit only NON-ADT paths and only when the admin setsSAP_ALLOW_PLUGIN_RAW_WRITES=true(+SAP_ALLOW_WRITES=true) and the tool declaresscope:'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 v2ctx.writevocabulary). CSRF is fetched + attached automatically. Use this for OData / custom-ICF write services.ctx.clientis a runtime plain-read view —.http/.safetyAND the data/SQL reads (getTableContents/runQuery/runTableQuery) are blocked at runtime (a cast yieldsundefined). v1 plugins have no data/SQL surface; use the plain read methods (orctx.http.get).policy.opTypemust matchscope— the declared scope has to cover the opType's required scope (e.g.opType:'U'needsscope:'write'), or the plugin fails server start. Keep them consistent with the examples above.- Named execution is privileged.
ctx.run.classRun(name)runs anIF_OO_ADT_CLASSRUNconsole class;ctx.run.programRun(name)runs an on-premise classic report and returns its list/error text (no selections or variants). Both needSAP_ALLOW_PLUGIN_EXECUTE=trueandSAP_ALLOW_WRITES=trueand awrite-scoped tool; names are validated. 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 byGET …/$metadata(200 = right; 403 = wrong path or genuinely inactive →/IWFND/MAINT_SERVICE). - OData V2 create gotcha: a
POSTmust not carry a$format=jsonquery option (it's a SystemQueryOption →400 "not allowed for this Request Type"). Negotiate JSON via theAcceptheader instead. Required entity fields vary —GET …/<EntitySet>?$top=1to 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 usesPOSTfor reads too, those read tools STILL needSAP_ALLOW_PLUGIN_RAW_WRITES+scope:'write'(ctx.httpgates by HTTP method) — declareopType: Readto keep the operation honest; (2) a write may require an open transport request — create one withSAPTransport(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:falserequired,pathis a template with no host, path params percent-encoded (traversal-safe). No POST/body in v1. availableOn: 'onprem' | 'btp'(optional, defaultall) hides the tool fromtools/listwhen the resolved system type differs. Hyperfocused mode shows no plugin tools at all.elicit/notify/samplingonctxare capability-gated — present only when the MCP client supports them (absent on the CLI/stdio path).- Unit-test the handler with
createMockToolContextfromarc-1/public/testing(recordsctx.http/ctx.run.classRun/ctx.run.programRuncalls, returns configured output — no live SAP needed). - Admin kill switch:
SAP_DENY_ACTIONS=Custom_*(all) orCustom_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).