A sample ARC-1 extension — the playground for FEAT-61. Pure TypeScript, no ABAP.
Consumes the
@experimentalarc-1/publicAPI. It is the smoke test for the FEAT-61 extension framework (arc-1docs/research/extension-framework-spec.md). Verified live against a real S/4HANA system — both tiers return real SAP source. The API may break in any release (it declaresapiVersion).
| Tool | SAP API | Style |
|---|---|---|
Custom_ProgramLineCount |
ADT (/sap/bc/adt/...) |
code tier (GET + logic) |
Custom_QuerySalesOrders |
OData (GWSAMPLE_BASIC) |
code tier (GET, Accept: application/json) |
Custom_ReadProgram |
ADT | manifest tier (declarative JSON) |
Custom_RunClass |
ADT classrun | code tier — executes an IF_OO_ADT_CLASSRUN console class |
Custom_RunReport |
ADT programrun | code tier — executes a classic report and returns its list output |
Custom_CreateSalesOrder |
OData (GWSAMPLE_BASIC) |
code tier — writes (ctx.http.post, gated; HTTP 201 verified) |
Custom_ListLanguages |
custom ICF (LISA ZI18N_SERVICE) |
code tier — list languages (POST; HTTP 200 verified) |
Custom_GetTranslation |
custom ICF (LISA ZI18N_SERVICE) |
code tier — read a translation (POST; HTTP 200 verified) |
Custom_SetTranslation |
custom ICF (LISA ZI18N_SERVICE) |
code tier — write a translation (POST; HTTP 200 verified) |
Custom_RfcSystemInfo |
classic RFC (RFC_SYSTEM_INFO via open-rfc) |
code tier — read off ctx.http, so it brings its own controls (below) |
Reads go through the gated ctx.http (GET/HEAD) → checkOperation + scope + audit.
Custom_RunClass and Custom_RunReport use ARC-1's named, gated ADT operations
(ctx.run.classRun / ctx.run.programRun). No custom ICF service is needed for either.
Custom_CreateSalesOrder and the LISA tools write via ctx.http.post to a non-ADT path
(OData / custom ICF). ADT object writes (CLAS/DDLS/…) stay a v2 item (the package-aware
ctx.write vocabulary) — see arc-1 docs/research/extension-framework-v2-spec.md.
LISA is a translation MCP server backed by a custom ABAP ICF
service (ZCL_I18N_SERVICE → POST /sap/bc/http/sap/ZI18N_SERVICE/<action>, JSON body). These three
tools run LISA's full read → write → read-back flow directly as ARC-1 extension tools — gated
ctx.http.post to that non-ADT path. Import LISA's handler class first, then:
# all LISA tools need the write opt-ins (see the note below):
ENV="SAP_ALLOW_PLUGIN_RAW_WRITES=true SAP_ALLOW_WRITES=true ARC1_PLUGINS=$PWD/dist/index.js"
env $ENV arc1-cli call Custom_ListLanguages --json '{}'
env $ENV arc1-cli call Custom_GetTranslation --json '{"objectName":"BUKRS","language":"EN"}'
env $ENV arc1-cli call Custom_SetTranslation --json '{"objectName":"ZARC1_I18N","language":"DE","transport":"A4HK9xxxxx","texts":[{"attribute":"short_field_label","value":"Hallo"}]}'
# → HTTP 200; the write records into the transport (live-verified on a4h / S/4HANA 2023).Why even the read tools need
SAP_ALLOW_PLUGIN_RAW_WRITES+scope: 'write': LISA exposes every action — including reads — as aPOST.ctx.httpgates by HTTP method, so anyPOSTneeds the write opt-in regardless of what it semantically does. That's a consequence of LISA's API design, not ARC-1's; the read tools declareopType: Readto keep the operation honest.set_translationrequires a real (open) transport — LISA records the write into it.
# needs BOTH opt-ins + a write-scoped tool (the tool declares scope:'write'):
SAP_ALLOW_PLUGIN_RAW_WRITES=true SAP_ALLOW_WRITES=true \
ARC1_PLUGINS=$PWD/dist/index.js \
arc1-cli call Custom_CreateSalesOrder --json '{"note":"hello"}'
# → HTTP 201 + the created SalesOrder (live-verified on a4h / S/4HANA 2023).
# With either opt-in off the call is refused; a write to a /sap/bc/adt/ path is always refused.ctx.run.programRun executes an active classic executable report (PROG) through SAP's native ADT
program-run endpoint and returns its classic WRITE list as plain text. No custom ICF service or
SAP_ALLOW_PLUGIN_RAW_WRITES is needed.
SAP_ALLOW_PLUGIN_EXECUTE=true SAP_ALLOW_WRITES=true \
ARC1_PLUGINS=$PWD/dist/index.js \
arc1-cli call Custom_RunReport \
--json '{"reportName":"ZARC1_TEST_REPORT"}'
# → the report list as textThe endpoint is deliberately name-in/text-out: it does not accept selection-screen parameters or
a variant. Use a small IF_OO_ADT_CLASSRUN class when runtime input is required. Report execution is
a mutation vector even when a report appears read-only, so ARC-1 requires the same three gates as
classRun: SAP_ALLOW_PLUGIN_EXECUTE=true, SAP_ALLOW_WRITES=true, and the write scope. The
sample is availableOn: 'onprem'; SAP still applies the calling user's execute authorization. As
with classRun, SAP can return execution errors such as Error: Program does not exist! as text
with HTTP 200, so the ARC-1 tool call itself has a successful status. The sample returns that text
verbatim and does not infer success from the transport status.
Custom_RfcSystemInfo calls the read-only RFC_SYSTEM_INFO function module over classic RFC,
using open-rfc — an SDK-free RFC client with no native
addon and no runtime dependencies, which is what makes RFC-from-a-plugin possible at all (the
NW RFC SDK never was).
It is the one tool here that does not go through ctx.http. ToolContext has no RFC channel,
so the tool opens its own socket with its own credentials. Know exactly what that changes:
a ctx.http tool |
this RFC tool | |
|---|---|---|
Safety ceiling (SAP_ALLOW_WRITES, SAP_ALLOWED_PACKAGES, denyActions) |
enforced on the call | not enforced on the call |
| SAP identity | per-user via principal propagation | one shared technical RFC user |
| Transport | HTTPS | classic RFC has no peer authentication; on CF it is carried inside the managed Connectivity / Cloud Connector tunnel |
| Reachable surface | the one path the tool writes | every remote-enabled FM that RFC user may call |
ARC-1 still gates whether the tool may be invoked (policy.scope, denyActions, audit). It does
not gate what the tool does once running. So the controls are the plugin's own:
- The function module is a hardcoded constant, never a parameter. This is the control that
matters most. A
Custom_RfcCall({ fm, params })tool would hand any caller — or any prompt-injected LLM — a generic RFC gateway into your backend. Don't build that one. - No inputs at all (
z.object({})). Nothing from the caller reaches the wire. - Default-off opt-in —
SAMPLE_RFC_ENABLED=true. ARC-1's ceiling does not reach RFC, so the plugin ships its own gate, in the same shape as the server'sSAP_ALLOW_PLUGIN_*opt-ins. - A dedicated credential namespace —
SAMPLE_RFC_*, deliberately notSAP_USER/SAP_PASSWORD. Those are ARC-1's own ADT credentials; borrowing them would dial RFC as ARC-1's HTTP identity instead of a least-privilege RFC user. - A response field allowlist.
RFC_SYSTEM_INFOalso returns the database host and the server's IPv4/IPv6 addresses. The tool discloses seven identifying fields and drops that topology. - Error redaction. open-rfc messages can carry the backend endpoint (a failed connect reads
failed to connect NI socket to <host>:<port>). The caller gets a classification key; the full error goes to the operator's stderr log. - Attribution logging. SAP's log records only the shared technical user, so the tool logs the
MCP
userName+requestIdbefore dialing — that link exists nowhere else. - A bounded call (15 s) and
close()infinally.
An RFC client needs authorization for the metadata function groups on top of the target FM —
open-rfc reads the function interface before it can serialize the call. Verified on S/4HANA 2023: a
user holding only SRFC logs on fine and then fails with RFC_NO_AUTHORITY on
RFC_GET_FUNCTION_INTERFACE. Minimum grant (S_RFC, ACTVT = 16, RFC_TYPE = FUGR):
RFC_NAME |
Why | Function modules |
|---|---|---|
SRFC |
the target FM | RFC_SYSTEM_INFO, RFC_PING |
RFC1 |
classic metadata | RFC_GET_FUNCTION_INTERFACE |
SDIFRUNTIME |
DDIC field info for structure parameters | DDIF_FIELDINFO_GET |
Add RFC_METADATA only if you want open-rfc's optimized metadata path. Function-group membership
above was read live from TFDIR/ENLFDIR on S/4HANA 2023.
Two things that set is chosen to exclude:
SDTX— the home ofRFC_READ_TABLE, the classic mass-exfiltration RFM. Granting the three groups above does not grant it. Never putRFC_READ_TABLEbehind an MCP tool.- Everything else.
S_RFCwithRFC_NAME = *on a technical user is how RFC becomes dangerous.
Caveat worth knowing: many systems exempt
SRFCfrom RFC authority checks entirely (auth/rfc_authority_check). Where that is set, this tool succeeding proves the connection works — it does not prove yourS_RFCdesign is correct. Validate that with a function group that is actually checked.
Beyond authorization, classic RFC is cleartext: keep it on a trusted network segment, or put SNC in front of clients that support it. This sample and open-rfc do not support SNC. Treat the RFC user as a shared service identity and give it nothing it does not need.
On Cloud Foundry the tool reads exactly one bound BTP Connectivity service, obtains a short-lived
OAuth token with its client credentials, and passes open-rfc the documented SOCKS5 host/port/token
tuple. SAMPLE_RFC_GWHOST and SAMPLE_RFC_GWSERV select a dedicated Cloud Connector TCP
virtual mapping to the SAP gateway (33NN); SAMPLE_RFC_ASHOST remains the actual application
server identity carried by CPIC. SAMPLE_RFC_LOCATION_ID is optional.
This route requires open-rfc 0.2.3 or newer. The package dependency is pinned to exactly 0.2.3,
not a range, so deployments use the reviewed release artifact published to npm rather than an
unbuilt Git checkout or an automatically selected future version.
This is deliberately not the Connectivity service's separate RFC-proxy endpoint. The generic TCP
mapping is opaque, so Cloud Connector cannot enforce an RFC function-module resource allowlist on
it. Cloud Connector's Trusted Applications allowlist applies only to Neo, not Cloud Foundry. For CF,
restrict who can create or consume Connectivity service bindings in the connected subaccount,
isolate production and non-production subaccounts/spaces, expose one exact virtual host and gateway
port, and enforce the function boundary with the dedicated technical user's exact S_RFC role
described above. The tool still hardcodes one RFM and accepts no caller-controlled wire values.
Configure Cloud Connector and CF in this order:
- Connect Cloud Connector to the BTP subaccount containing the ARC-1 CF application. Use a separate production subaccount/connector configuration. Record its Location ID if one is configured.
- In Cloud Connector, add an ABAP System with protocol TCP. Do not select RFC or RFC SNC: this sample uses the Connectivity SOCKS5/TCP endpoint, not the separate RFC proxy.
- Set the internal host to the S/4HANA application server reachable from Cloud Connector and the
internal port to its RFC gateway
33NN(3300for system number00). Choose one virtual host and one virtual port and do not expose a host or port range. - In CF, create and bind one BTP Connectivity service instance to ARC-1 and restage the app. This sample supports the default client-secret binding only. X.509/mTLS Connectivity bindings are rejected explicitly because their token-acquisition flow is not implemented here.
- Set
SAMPLE_RFC_GWHOSTandSAMPLE_RFC_GWSERVto the Cloud Connector virtual values. SetSAMPLE_RFC_ASHOSTto the actual SAP application-server identity carried inside CPIC. SetSAMPLE_RFC_LOCATION_IDonly when it exactly matches the connector's Location ID. - Keep the SAP technical user limited to the exact
S_RFCfunction groups above. Restrict CF org/space membership, service-key creation, binding operations, and deployment credentials: possession of the Connectivity binding credentials is the application-side tunnel authority.
The managed BTP-to-Cloud-Connector tunnel is TLS-protected. The internal Cloud-Connector-to-SAP-gateway hop still carries classic RFC without SNC or end-to-end peer authentication, so keep it on a trusted segmented network. Choosing TCP TLS is valid only when the mapped backend endpoint actually speaks the matching TLS protocol; it does not add SNC to an ordinary SAP gateway.
SAP references: TCP/SOCKS5 for cloud applications, create and bind Connectivity, Cloud Connector access control, and subaccount separation.
Binding selection and token handling fail closed: malformed or multiple Connectivity bindings,
missing SOCKS5 credentials, an insecure token URL, an OAuth rejection, a partial route, or a token
with a Bearer prefix stops before the SAP socket opens. The token is cached only until shortly
before expiry and is never included in tool output or inspected configuration. The sample reads
onpremise_proxy_host and onpremise_socks5_proxy_port; it never uses
onpremise_proxy_rfc_port.
export SAMPLE_RFC_ENABLED=true
export SAMPLE_RFC_ASHOST=your-app-server SAMPLE_RFC_SYSNR=00
export SAMPLE_RFC_CLIENT=001 SAMPLE_RFC_LANG=EN
export SAMPLE_RFC_USER=RFC_READONLY SAMPLE_RFC_PASSWD=... # least-privilege user, see above
# additionally on BTP CF, using the Cloud Connector TCP virtual mapping (not its internal target):
export SAMPLE_RFC_GWHOST=virtual-rfc-host SAMPLE_RFC_GWSERV=3300
# export SAMPLE_RFC_LOCATION_ID=optional-location
ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_RfcSystemInfo --json '{}'
# → RFCSYSID / RFCSAPRL / RFCKERNRL / RFCOPSYS / RFCDBSYS / RFCHOST / RFCTZONE
# With SAMPLE_RFC_ENABLED unset the call is refused. Live-verified on a4h (S/4HANA 2023).npm test runs the redaction + allowlist checks (node:test, no SAP system required).
Executing ABAP is gated — all of these are required (else the call is refused):
# the class must implement IF_OO_ADT_CLASSRUN; e.g. create ZCL_ARC1_RUN_DEMO with a main( ) that
# calls out->write( ... ), then:
SAP_ALLOW_PLUGIN_EXECUTE=true SAP_ALLOW_WRITES=true \
ARC1_PLUGINS=$PWD/dist/index.js \
arc1-cli call Custom_RunClass --json '{"className":"ZCL_ARC1_RUN_DEMO"}'
# → the class's console output (out->write) as textThe tool declares policy.scope: 'write', so the caller also needs the write scope. Live-verified
against a4h (S/4HANA 2023) — returns the real console output; with the opt-in off it is refused.
# 1. 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
# 2. load into an arc-1 instance…
ARC1_PLUGINS=$PWD/dist/index.js arc1 --http-streamable
# …or drive one call via the CLI:
ARC1_PLUGINS=$PWD/dist/index.js arc1-cli call Custom_ProgramLineCount --json '{"name":"RSPARAM"}'- Tool names are
Custom_*(reserved namespace; collisions fail-fast at load). - Each tool declares
policy: { scope, opType }— gated exactly like a built-in (reuses the 7 scopes + allow* ceiling; no custom scopes). package.json#arc1.requiresdeclares the scopes/packages the plugin needs. This is a v2 declaration (intersected with the server ceiling, never expands it) — not yet enforced in v1, where the runtime scope + safety ceiling already gate every call. Kept here as a forward example.- Pure TS — no ABAP artifacts. Custom endpoints (if any) must already exist on the SAP system.
Working + live-verified against a4h (S/4HANA 2023): code-tier Custom_ProgramLineCount and
manifest-tier Custom_ReadProgram return real ABAP source through the gated ctx.http, and
Custom_RunClass executes a console class (ctx.run.classRun) and returns its real output — with the
three safety gates (opt-in off / allowWrites off / bad class name) all refusing as expected.
Custom_RunReport is also live-verified: it executed ZARC1_TEST_REPORT through
ctx.run.programRun, returned the report list, preserved SAP's text response for a missing program,
and refused calls when either server gate was disabled or the name was invalid.