create-arc1-extension
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).
How do I install this agent skill?
npx skills add https://github.com/arc-mcp/arc-1 --skill create-arc1-extensionIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides developer guidance and code templates for building extensions for the ARC-1 MCP server. It includes standard software development practices, build commands, and references to the vendor's official sample repository.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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_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).
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/arc-mcp/arc-1/create-arc1-extension">View create-arc1-extension on skillZs</a>