Extensions (Custom Tools)¶
ARC-1 is extensible: you can add your own Custom_* tools to an ARC-1 instance without
forking β they reuse ARC-1's authenticated SAP client, its safety ceiling, scope policy, audit,
and per-user principal propagation. This is the FEAT-61 extension framework.
Experimental
The extension API (arc-1/public) is @experimental β it may break in any release. A plugin
declares a single apiVersion integer as the compatibility fuse. No semver guarantee yet.
- Worked sample:
arc-mcp/arc-1-extension-sampleβ two code tools + one manifest tool, live-verified against S/4HANA. - Guided setup: the
create-arc1-extensionskill (.claude/skills/create-arc1-extension/) walks you through the decisions, scaffolds the plugin, and points out the security implications for your use case. - Design:
docs/research/extension-framework-spec.md(spec) +extension-framework-deep-research.md(rationale).
Extension, or a separate server?¶
The first decision. An extension runs in-process and talks to the same SAP system ARC-1 is connected to, over HTTP.
| Your tool talks to⦠| Build a⦠|
|---|---|
| the same SAP system over HTTP β ADT, OData, or a custom ICF/REST service | Extension (this page) |
| a different SAP product (Cloud ALM, BTP services, BW, HANA, Datasphere, SuccessFactors) | separate MCP server (on the BTP-auth module) |
| a non-HTTP protocol (native RFC, SAP GUI scripting) | separate MCP server |
Extensions never ship ABAP β any custom endpoint they call must already exist on the SAP system.
The two tiers¶
| Tier | What you write | Use when |
|---|---|---|
Code (defineTool, TypeScript) |
a handler function | you need logic, response shaping, or multiple reads |
Manifest (*.tool.json, no code) |
one JSON file declaring input β one GET |
you just wrap a single read endpoint |
Both produce a Custom_* tool, gated identically.
v1 is read-only β with one gated exception
Both tiers are read-only in v1: ctx.http exposes GET/HEAD only. General write/POST
support is deferred to v2 because a raw write can't be constrained by SAP_ALLOWED_PACKAGES
(package resolution needs the ADT object-URL shape); shipping un-package-gated writes would bypass
the server safety ceiling. The one privileged op available in v1 is executing a console class
(ctx.run.classRun, see below) β a named operation (not a generic POST, so no package-bypass),
locked behind a default-off opt-in. v2 adds the full package-aware write vocabulary.
Quickstart¶
Clone the sample and adapt it:
git clone https://github.com/arc-mcp/arc-1-extension-sample
cd arc-1-extension-sample
# link the local arc-1 build (until arc-1 is published with the public API)
( cd /path/to/arc-1 && npm link )
npm install && npm link arc-1 && npm run build
# load into an ARC-1 instanceβ¦
ARC1_PLUGINS=$PWD/dist/index.js arc1 --transport http-streamable
# β¦or drive one call (args are --json, never positional):
ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_ProgramLineCount --json '{"name":"RSPARAM"}'
ARC1_PLUGINS is a CSV of absolute paths. An entry is either a .js code plugin (point at the
built module, e.g. dist/index.js) or a bare *.tool.json manifest. Loading is fail-fast β a
malformed plugin or a name collision refuses server start.
The plugin contract¶
Code tier¶
import { z } from 'zod';
import { defineTool, OperationType } from 'arc-1/public';
export default defineTool({
name: 'Custom_ProgramLineCount', // MUST start with Custom_ (reserved namespace)
description: 'Report the line count of an ABAP program.',
schema: z.object({ name: z.string().min(1).max(40) }),
policy: { scope: 'read', opType: OperationType.Read }, // declared capability β see Security below
async handler(args, ctx) {
const res = await ctx.http.get(`/sap/bc/adt/programs/programs/${encodeURIComponent((args as { name: string }).name)}/source/main`,
{ Accept: 'text/plain' });
return { content: [{ type: 'text', text: `${res.body.split('\n').length} lines` }] };
},
});
A Plugin default export collects tools + manifests:
export default { name: 'my-ext', version: '0.1.0', apiVersion: 1, tools: [...], manifests: ['manifests/Custom_X.tool.json'] } satisfies Plugin;
Manifest tier¶
{
"name": "Custom_ReadProgram",
"description": "Read an ABAP program's source.",
"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/programs/programs/{name}/source/main",
"pathParams": { "name": "$.name" }, "accept": "text/plain" },
"response": { "maxBytes": 50000 }
}
v1 manifests are read-only GET: additionalProperties:false is required, path is a template with
no host, and path params are percent-encoded (traversal-safe).
Calling SAP APIs¶
Everything goes through ctx.http β a gated, read-only (GET/HEAD) wrapper over ARC-1's
authenticated client. It can reach any SAP path on the connected system, with auth, CSRF, cookies,
per-user PP, and sessions handled for you:
| API | Example |
|---|---|
| ADT | ctx.http.get('/sap/bc/adt/programs/programs/ZFOO/source/main') |
| OData | ctx.http.get('/sap/opu/odata/sap/ZSVC/EntitySet?$filter=β¦') (caller Accept: application/json) |
| custom ICF/REST | ctx.http.get('/sap/bc/http/sap/zmyservice') (endpoint must already exist) |
The raw client is never exposed β ctx.client offers high-level reads only; its .http/.safety
escape hatches are blocked at runtime (a (ctx.client as any).http cast yields undefined), not
just hidden by types.
OData/ICF specifics
A service must be activated in /IWFND even if it appears in the catalog (a 403 "No service
found" means it is registered but not activated).
Executing ABAP (console classes)¶
The one privileged operation a v1 plugin can perform is running an ABAP console class β a class
that implements IF_OO_ADT_CLASSRUN (the modern replacement for executable reports on ABAP Cloud).
It runs through ctx.run.classRun(name), which returns the class's out->write( β¦ ) console
output:
export default defineTool({
name: 'Custom_RunClass',
description: 'Execute an ABAP console class and return its console 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);
return { content: [{ type: 'text', text: out }] };
},
});
Executing arbitrary ABAP can mutate anything, so this is the strictest-gated capability in the
framework β all of the following must hold, or the call is refused with an AdtSafetyError:
| Gate | Why |
|---|---|
SAP_ALLOW_PLUGIN_EXECUTE=true |
a dedicated opt-in (default off) β enabling built-in writes never silently grants plugins code execution |
SAP_ALLOW_WRITES=true |
execution is a mutation vector; keeps the allowWrites=false β no mutation guarantee |
tool declares scope: 'write' |
a read-scoped tool can never execute |
user has the write scope + SAP-side execute auth |
the usual scope β§ SAP-auth |
classRun is a named op (not a raw POST), so a plugin can only run a class by name (validated,
no path injection) β it cannot reach arbitrary write endpoints. That's why it can ship in read-only v1
safely; the general write surface still waits for v2.
Security & roles (by use case)¶
A plugin is trusted code, not a sandbox
A code plugin is import()-ed into the ARC-1 process and runs with the full privileges of the
server: it can read process.env (SAP credentials, the XSUAA clientsecret, the DCR signing
secret), read/write the local filesystem, open outbound network connections, and spawn processes.
The gated ctx (read-only ctx.http, the blocked ctx.client, the classRun gate) is a clean
API surface that protects against a buggy or over-eager plugin and honours the admin's posture
β it is not a containment boundary against a hostile one (a malicious plugin doesn't need
ctx; it has child_process). Loading a plugin is exactly as much a trust decision as adding a
dependency to ARC-1 itself. Only load plugins you have reviewed, and:
- Vet the supply chain. A code plugin's transitive
node_modulesrun in-process β a compromised dependency is a full ARC-1 compromise. Commit a lockfile, keep dependencies minimal,npm audit, and prefer the manifest tier (no code, no deps) when one GET suffices. - Bake into an immutable artifact. Ship plugins inside the reviewed deploy image / app bits, under the same change control as the rest of the server (see Deploying).
This is the most important part. An extension tool inherits ARC-1's full safety pipeline β it is
gated exactly like a built-in. Two layers must both pass: the user's scope (their MCP role/profile)
and the server's safety ceiling (the admin's allow* flags). Per-user principal propagation
means the tool acts as the calling SAP user, so SAP-side auth (S_DEVELOP, package checks) applies too.
Declare policy: { scope, opType } to match the operation your tool performs. The user's scope must
cover it (a read user never sees a write-scoped tool), and the server ceiling must allow it.
| Use case | scope |
opType |
Server flag the admin must set | The user needs (XSUAA role / OIDC scope / API-key profile) |
|---|---|---|---|---|
| Read-only diagnostic (ADT/OData/ICF) | read |
R |
β | read |
| Create / update / delete an ABAP object (v2) | write |
C/U/D |
SAP_ALLOW_WRITES=true + target package in SAP_ALLOWED_PACKAGES |
write |
| Table-content preview (v2) | data |
Q |
SAP_ALLOW_DATA_PREVIEW=true |
data |
| Free-style SQL (v2) | sql |
F |
SAP_ALLOW_FREE_SQL=true |
sql |
| Transport operation (v2) | transports |
X |
SAP_ALLOW_TRANSPORT_WRITES=true |
transports |
Since v1 ctx.http is read-only, only the read row is live today; the rest document the model for the
v2 write surface (and the package-allowlist enforcement that ships with it).
Key points:
customscopes are not supported. Reuse the 7 built-in scopes β XSUAA scopes are deploy-time static (xs-security.json), so reuse maps cleanly to existing roles. See Authorization & Roles.- Admins keep the kill switch.
SAP_DENY_ACTIONS=Custom_*removes all plugin tools;SAP_DENY_ACTIONS=Custom_Fooremoves one. - Code execution is opt-in + default off.
ctx.run.classRunrequiresSAP_ALLOW_PLUGIN_EXECUTE=trueandSAP_ALLOW_WRITES=trueand awrite-scoped tool (see Executing ABAP). - System-type visibility. A tool may declare
availableOn: 'onprem' | 'btp'(defaultall); it is hidden fromtools/listwhen the resolved system type is known and differs. - Trust model: plugins are trusted in-process code (see the danger callout above), loaded
only from local
ARC1_PLUGINSpaths an admin opts into β no marketplace, no runtime upload, no sandbox by design. Thectxgates bound a buggy plugin and the server's posture, not a hostile one. policy.opTypeis checked at registration, not per HTTP call. The declaredscopemust cover theopType's required scope (a tool can't claimreadwhile declaring a write op, else it fails-fast at load). In v1 the runtime gates are the read-onlyctx.httpandclassRun's own checks;opTypeis reused for v2's write gating.
Interactive capabilities¶
When the MCP client supports them, ctx also offers (capability-detected β undefined otherwise):
ctx.elicit(message, schema?)β ask the user for input mid-tool.ctx.notify(level, message)β send a client-visible progress line.ctx.sampling(systemPrompt, userMessage)β ask the LLM a sub-question.
Testing¶
Unit-test a handler with no live SAP using createMockToolContext from arc-1/public/testing β it
records ctx.http calls and returns a configured body:
import { createMockToolContext } from 'arc-1/public/testing';
const ctx = createMockToolContext({ responseBody: 'REPORT ZX.\nWRITE 1.' });
const res = await myTool.handler({ name: 'ZX' }, ctx);
expect(ctx.httpCalls[0].path).toContain('/programs/ZX/');
Deploying extensions (BTP Cloud Foundry / Docker)¶
A plugin is a local file the server loads at startup from an absolute ARC1_PLUGINS path
(it's a literal CSV β no $HOME/shell expansion). On a managed deployment the container
filesystem comes from the deploy artifact, so "getting the plugin onto a stable absolute path" is the
whole problem. Three ways, with trade-offs:
| Strategy | How | Upside | Downside |
|---|---|---|---|
| Derived Docker image (recommended) | FROM ghcr.io/arc-mcp/arc-1, COPY --chown the plugin's dist/, set ENV ARC1_PLUGINS=β¦ |
self-contained + version-pinned with ARC-1; one immutable artifact through your image review/supply chain; identical local / CFβDocker / k8s | rebuild + repush to change a plugin; needs a registry; must --chown (see gotcha) |
Buildpack co-deploy (matches the committed mta.yaml, nodejs_buildpack) |
put the plugin's built dist/ in the pushed app bits (e.g. plugins/<name>/), set ARC1_PLUGINS=/home/vcap/app/plugins/<name>/dist/index.js |
no image build; plain cf push / mta build; bits are vcap-owned so the owner check passes |
the plugin rides ARC-1's deploy bits (coupled); rebuild the bits to change it |
| Volume service (NFS) | mount a CF volume, point ARC1_PLUGINS at it |
swap a plugin without rebuilding the image/bits | plugin lives outside the audited artifact (trust gap); the mount's uid/permissions must satisfy the loader's owner + notβworldβwritable checks; still needs a restart |
Derived Docker image β the recipe¶
FROM ghcr.io/arc-mcp/arc-1:latest
# ARC-1 runs as the non-root user `arc1`. A plain COPY lands files as root β the loader rejects them.
COPY --chown=arc1:arc1 dist/ /home/arc1/plugins/myext/dist/
COPY --chown=arc1:arc1 manifests/ /home/arc1/plugins/myext/manifests/
ENV ARC1_PLUGINS=/home/arc1/plugins/myext/dist/index.js
cf push my-arc1 --docker-image <registry>/my-arc1:<tag> (or k8s / local docker run).
The owner / permission gotcha (bites on Docker)¶
The loader refuses a plugin file that is not owned by the server process user or is
world-writable β defense-in-depth against a tampered drop-in. ARC-1's image runs as arc1, but a
plain COPY lands files as root β "Plugin β¦ is not owned by the server user β refusing to load".
Fix: COPY --chown=arc1:arc1, and never chmod 777 a plugin. On the buildpack the bits are
already vcap-owned, so this is a non-issue there.
Cross-cutting¶
- No hot-reload. Plugins load once at startup; changing one means a redeploy /
cf restage. TheapiVersioninteger is the compatibility fuse across ARC-1 upgrades. - Adding a plugin needs NO XSUAA change. Plugin tools reuse the 7 built-in scopes (no custom
scopes), so you do not touch
xs-security.jsonor role collections to ship a newCustom_*tool β a real operational win on BTP. - Per-user principal propagation still applies β a plugin's
ctxcarries the per-user (PP) SAP client, so its calls run as the calling SAP user, same as built-in tools. - Execution is per-deployment opt-in.
SAP_ALLOW_PLUGIN_EXECUTE/SAP_ALLOW_WRITESare server env (cf set-env/ MTA) β set them only where you intend plugins to run classes. - Trust = supply chain. Plugins are baked into the deploy artifact and reviewed with it; there is
no runtime upload. Keep
ARC1_PLUGINSunder the same change control as the rest of the app.
Roadmap (v2)¶
v1 is read-only on purpose. The biggest v2 item is a package-aware write surface β a
ctx.write vocabulary that routes ADT object writes through the same package-allowlist gate built-in
SAPWrite uses (so a plugin still can't write outside SAP_ALLOWED_PACKAGES), plus opt-in raw writes
for package-less OData/ICF calls. Also planned: a safe per-user ctx.cache, directory + npm-package
loading, package.json#arc1.requires capability intersection, per-handler timeouts, and graduating
the API from @experimental to semver-stable. Full design:
docs/research/extension-framework-v2-spec.md.
Reference¶
- Sample repo: https://github.com/arc-mcp/arc-1-extension-sample
- Guided skill:
create-arc1-extension(.claude/skills/create-arc1-extension/) - Spec & research:
docs/research/extension-framework-spec.md,extension-framework-deep-research.md - Related: Authorization & Roles Β· Tools Reference Β· CLI Guide