b1-mcp-server for SAP B1
<p align="center">
<a href="https://www.npmjs.com/package/b1-mcp-server"><img src="https://img.shields.io/npm/v/b1-mcp-server" alt="npm version"></a>
<a href="https://www.npmjs.com/package/b1-mcp-server"><img src="https://img.shields.io/npm/dm/b1-mcp-server" alt="npm downloads"></a>
<a href="LICENSE"><img src="https://img.shields.io/npm/l/b1-mcp-server" alt="license"></a>
<img src="https://img.shields.io/node/v/b1-mcp-server" alt="node version">
<a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-server-blue" alt="MCP server"></a>
<img src="https://img.shields.io/badge/SAP-Business%20One-0FAAFF?logo=sap&logoColor=white" alt="SAP Business One">
</p>
<p align="center"><img src="assets/logo.png" alt="b1-mcp-server logo" width="160"></p>
# b1-mcp-server
A secure [Model Context Protocol](https://modelcontextprotocol.io) server that
gives an AI client (e.g. Claude Code) guarded access to SAP Business One via
both the database (HANA / MS SQL) and the Service Layer OData API.
## Features
- **7 MCP tools**: `connect_database`, `execute_sql`, `execute_service_layer`
(GET), `execute_service_layer_write` (PATCH / POST / DELETE),
`execute_service_layer_write_unattended` (the same writes without approval,
development profiles only), `get_schema_info`, `check_connection`.
- **Multi-environment**: switch between client databases at runtime via named
connection profiles. No restart needed.
- **Read-only by design**: the SQL path executes only `SELECT`. Every
`INSERT/UPDATE/DELETE/DROP/CREATE/ALTER/EXEC` is blocked server-side with no
confirmation bypass — writes are handed to a human to run in a real DB client.
- **Server-side guardrails**: all raw SQL submitted to `execute_sql` goes
through the same validation whether the user or the AI wrote it. Anonymous
`DO BEGIN..END` blocks are classified by the most dangerous statement inside
them, so a write hidden behind a trailing `SELECT` is still a write.
- **Audit trail**: selected-profile connection attempts and database/Service
Layer calls that reach policy or execution are recorded as JSON Lines,
including policy denials.
- **Rate limiting** per tool with sliding window.
## Install
Requires Node.js 20 or newer.
```bash
npx b1-mcp-server
```
Or add to your Claude Code MCP config:
```json
{
"mcpServers": {
"b1-db": {
"command": "npx",
"args": ["-y", "b1-mcp-server"]
}
}
}
```
### Dependencies
Five runtime dependencies: `@modelcontextprotocol/sdk`, `zod`,
`@sap/hana-client` and `mssql` (plus `generic-pool`, which backs the HANA
connection pool). Database access is a local driver — `src/db/directDb.ts` —
built directly on the HANA and MS SQL clients; Service Layer traffic goes
through the strict-TLS adapter in `src/sl/serviceLayerAdapter.ts` using Node's
own `fetch` and `https`. No HTTP framework, no axios, no PostgreSQL driver.
A clean install audits at `found 0 vulnerabilities` with no `overrides` block,
which also means `npx -y b1-mcp-server` is clean — overrides never applied
there, since npx runs without a root `package.json`.
## Connection profiles
The server starts unconnected. Connections are loaded from
`~/.claude/connections.json` (one profile per environment) and selected at
runtime by the AI via the `connect_database` tool.
Profile matching behavior in `connect_database`:
- Exact match by `id` or `dbName` is preferred.
- Partial matches are accepted only when they resolve to a single profile.
- Ambiguous partial matches are rejected with a list of candidates.
Example `~/.claude/connections.json`:
```json
[
{
"id": "client_a_prd",
"dbType": "hana",
"dbServer": "10.0.0.10:30015",
"dbName": "SBO_CLIENT_A",
"dbUser": "B1ADMIN",
"dbPassword": "•••",
"slUrl": "https://10.0.0.10:50000/b1s/v2",
"slUser": "manager",
"slPassword": "•••",
"slAllowedMethods": ["GET", "PATCH", "POST"]
},
{
"id": "client_b_hmg",
"dbType": "mssql",
"dbServer": "10.0.0.20:1433",
"dbName": "SBO_CLIENT_B_HMG",
"dbUser": "SA",
"dbPassword": "•••"
}
]
```
Every profile requires `id`, `dbType` and `dbName`. Add `dbServer` and `dbUser`
to configure DirectDb; add `slUrl` and `slUser` to configure Service Layer.
Each configured side also requires its non-empty password (`dbPassword` or
`slPassword`). Missing/empty passwords fail locally without a login attempt,
avoiding an unnecessary failure that may count toward account lockout under the
configured login policy. Non-empty password strings are passed through without
trimming. Either side, or both sides, can be configured in one profile.
`slAllowedMethods` (optional) lists the HTTP verbs the Service Layer tools may
send for that profile: any of `GET`, `PATCH`, `POST`, `DELETE`
(case-insensitive). Without it a profile is read-only (`["GET"]`); every write
verb must be listed explicitly. `PUT` is not supported, because it
replaces the whole entity and wipes every field the body omits. An invalid
value (unknown verb, empty list, not an array) skips that profile instead of
guessing. The list is applied on connect and printed by `connect_database`;
editing it re-logs the Service Layer on the next `connect_database` call.
Allowing a write verb does not waive approval: every write still needs the
per-call human acceptance described under the security posture.
`slWriteApproval` (optional) chooses who approves a write. `"elicitation"`
(default): the server asks through an MCP approval form. `"client"`: the server
sends no form and relies on the MCP client's own permission prompt — for
setups where the form cannot reach the user, such as Claude Code Remote
Control, which forwards permission prompts but not MCP forms. `client` is
honoured only when the MCP client identifies itself as Claude Code; any other
client falls back to elicitation. The server cannot see that prompt, so the
mode is only as strong as the client's permission settings: list
`mcp__<server-name>__execute_service_layer_write` under `permissions.ask`,
never `allow`, and do not use it with `--dangerously-skip-permissions`. Without
that rule, answering the first prompt with "Yes, don't ask again" saves an
`allow` rule and every later write runs unprompted. Keep
production profiles on `elicitation`. `connect_database` prints the mode that
applies to the current client; editing it re-logs the Service Layer.
`"none"` is for development companies where a prompt on every write slows the
work down. `execute_service_layer_write` behaves as in `client` mode, and the
profile also unlocks `execute_service_layer_write_unattended`, which runs the
same writes with **no approval at all**, for any MCP client. That tool refuses
every profile not set to `"none"`, so you can list
`mcp__<server-name>__execute_service_layer_write_unattended` under
`permissions.allow` and keep the normal write tool under `permissions.ask`:
development profiles run unprompted and every other profile still prompts. The
opt-in lives only in the connections file, so it is only as safe as that file:
never set `"none"` on a profile that points at production data.
Both SAP Service Layer roots are supported: `/b1s/v1` for OData v3 and
`/b1s/v2` for OData v4. Login, health checks and relative Service Layer requests
use the exact root configured by the profile. Request validation is
version-neutral; callers pass only the relative endpoint (for example,
`Items?$select=ItemCode&$top=1`). OData query and payload differences remain
the caller's responsibility; configure the root supported by the target SAP B1
installation. SAP deprecated OData v3 starting with SAP Business One 10.0 FP
2405 and made OData v4 the primary protocol, so prefer `/b1s/v2` when the target
installation supports it; see the
[Service Layer API reference](https://help.sap.com/doc/056f69366b5345a386bb8149f1700c19/10.0/en-US/Service%20Layer%20API%20Reference.html).
## Security model
**The server does not intentionally interpolate profile passwords into MCP tool
responses or audit entries.** Passwords are read from
`~/.claude/connections.json` inside the server process and passed directly to
the configured database or Service Layer endpoint for login. The Service Layer
adapter retains only the session cookie afterward, not the username or
password. Database-driver and Service Layer error messages are returned and
audited verbatim for diagnosis, however, so there is no general-purpose secret
redaction boundary around arbitrary upstream error text. Do not place secrets
in profile IDs, database names, URLs or other non-password metadata. This
protocol-level protection also does not stop an AI host that has unrestricted
same-user shell or filesystem access from opening the file directly; see the
hook guidance below.
Service Layer connections require HTTPS with normal certificate validation by
default. For an internal certificate authority, set `NODE_EXTRA_CA_CERTS` to
the CA bundle before starting the MCP process.
For a legacy SAP installation whose certificate cannot be renewed, the first
`connect_database` call inspects TLS without sending credentials and requests
explicit approval through MCP form elicitation. An accepted certificate is
stored by canonical `https://host:port` origin in
`~/.claude/service-layer-trust.json` (mode `0600`), so profiles sharing one SL
endpoint share one pin. The file is created after explicit approval or after a
matching legacy manual profile pin is migrated successfully, and contains no
credentials. A changed certificate requires approval again; clients without
elicitation support fail closed. Legacy manual profile pin fields remain
accepted only for that one-time migration into this local store.
Once a certificate is pinned, that exact fingerprint is the whole check —
hostname verification is not applied on top of it. SAP B1 issues its self-signed
Service Layer certificate to the machine's short name (`DNS:MYSERVER`) while
clients normally connect by FQDN, so re-verifying the name would reject an
already-approved certificate with no way to accept it. `slTlsServerName` is
therefore only the SNI name sent during the handshake, for hosts that serve a
different certificate per name; it is not needed to make a pinned FQDN
connection work. Service Layer URLs may use DNS names, IPv4 or IPv6. For an IP
URL, SNI is omitted unless `slTlsServerName` supplies a DNS hostname; a legacy
IP-valued `slTlsServerName` is accepted as a no-op. Older trust-store records may
still carry `serverName` as a compatibility fallback, but new SNI configuration
belongs in the connection profile rather than in certificate trust metadata.
Profiles reload on every `connect_database` call. A DB-only profile can gain
`slUrl`, `slUser` and `slPassword` while its DB connection is active; the next
call keeps that DB connection and initializes only Service Layer.
Global certificate verification disablement is never supported: the adapter
refuses to connect when `NODE_TLS_REJECT_UNAUTHORIZED=0` is present.
What the AI does see when it calls `connect_database` with `"list"`:
- profile `id`
- `dbName`, `dbType`
- a capability flag (`DB`, `SL`, `DB+SL`)
- an `active` marker when either configured side is currently connected
Recommendations for your `connections.json`:
- Lock the file to your user only.
- Linux/macOS: `chmod 600 ~/.claude/connections.json`.
- Windows: `icacls "%USERPROFILE%\.claude\connections.json" /inheritance:r /grant:r "%USERNAME%:F"`.
- Do not commit it to any git repository or share it via cloud sync.
- On POSIX systems, the server fails closed and refuses to load a profile file
that grants any permission to group or other users.
- Each user maintains their own copy. Credentials never come from this npm
package.
### Claude Code hook and sandbox guidance
Use a centrally managed Claude Code `PreToolUse` hook or equivalent host policy
to deny AI-issued access to the canonical resolved path of
`connections.json`. Cover every filesystem-capable tool (`Read`, `Grep`,
`Glob`, edit/write tools and filesystem MCP servers), not only literal filename
matches. Shell commands are the difficult case: matching the path text is not
sufficient because commands, working directories and symlinks can access it
indirectly. Prefer a host filesystem sandbox that denies the credential path;
otherwise require approval for shell access outside the workspace.
The hook/policy must live outside AI-writable project and user settings. For
Claude Code enterprise deployments, install it in the platform's managed
settings so project instructions cannot override it. Deny AI writes to
`service-layer-trust.json` as well; reading that file is not secret, but its
integrity controls which legacy certificate is trusted.
Hooks are defense in depth, not an OS security boundary. They govern tool calls
issued through the AI host; they cannot stop this MCP process, another local
process, or a malicious npm package running as the same OS user from reading a
mode-`0600` file. Strong isolation requires a dedicated OS identity, sandbox,
or remotely hosted credential broker. See the
[Claude Code hooks](https://code.claude.com/docs/en/hooks) and
[server-managed settings](https://code.claude.com/docs/en/server-managed-settings)
guidance.
## Configuration (env vars)
All optional.
**There is no `.env` file.** The server reads `process.env` directly and never
loads a dotenv file, so dropping a `.env` next to it does nothing. For a stdio
MCP server the client owns the environment — set variables in the `env` block of
your MCP configuration, which the client passes to the spawned process:
```json
{
"mcpServers": {
"b1-db": {
"command": "npx",
"args": ["-y", "b1-mcp-server"],
"env": {
"MCP_QUERY_TIMEOUT_MS": "120000",
"MCP_SL_WRITES_ENABLED": "false"
}
}
}
}
```
Credentials are **not** configured this way — they live only in
`connections.json`, which is resolved from your home directory and therefore
found regardless of where `npx` runs from.
| Variable | Default | Purpose |
|---|---|---|
| `MCP_CONNECTIONS_FILE` | `~/.claude/connections.json` | Path to profile file |
| `MCP_AUDIT_LOG_PATH` | `~/.claude/logs/b1-mcp-audit.jsonl` | Audit log JSONL file. Unset or empty uses the default path |
| `MCP_LOG_LEVEL` | `info` | Stderr verbosity: `debug` / `info` / `warn` / `error`. JSONL file logging is unaffected |
| `MCP_MAX_QUERY_LENGTH` | `8000` | Max SQL length in characters |
| `MCP_RATE_LIMIT_MAX_CALLS` | `60` | Max calls per tool per window |
| `MCP_RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window |
| `MCP_QUERY_TIMEOUT_MS` | `60000` | Statement ceiling. A query still running at this point is aborted. Raise it if a legitimate report needs longer |
| `MCP_SL_TIMEOUT_MS` | `30000` | Service Layer login/request timeout |
| `MCP_SL_TRUST_FILE` | `~/.claude/service-layer-trust.json` | Local non-secret certificate trust store |
| `MCP_SL_MAX_URL_LENGTH` | `2048` | Maximum relative OData URL length |
| `MCP_SL_MAX_BODY_CHARS` | `50000` | Maximum serialised PATCH/POST body length |
| `MCP_SL_WRITES_ENABLED` | `true` | Emergency kill switch for **every** Service Layer write (PATCH, POST, DELETE); `true`/`false` are case-insensitive and invalid values disable writes. Replaces `MCP_SL_PATCH_ENABLED`, which is no longer read — except that any value other than `true` in the old name still disables writes, so an upgrade never re-opens them |
| `MCP_ELICITATION_TIMEOUT_MS` | `120000` | How long a human gets to answer an approval form (certificate trust, Service Layer write) before it fails closed |
| `MCP_MAX_RESULT_ROWS` | `500` | Max rows returned to the model; extra rows are cut and announced |
| `MCP_MAX_RESULT_CHARS` | `100000` | Max characters of result JSON, applied after the row cap |
| `MCP_DRY_RUN` | `false` | The exact value `true` validates raw SQL and Service Layer writes without executing them; connections and Service Layer GET still run |
The numeric limits above must be positive integers. An unset or whitespace-only
value uses the default. Any other invalid value (including `0`, a negative or a
non-integer) reports the fallback on stderr and uses the default — a
misconfigured limit never means "no limit". `MCP_LOG_LEVEL` likewise uses
`info` when unset/empty and reports a fallback for any other invalid value.
## Guardrail summary
The SQL path is **read-only**, so the live answer does not depend on which
table you touch:
| Operation | Any table (SAP core, UDT, custom, temp) |
|---|---|
| SELECT | allow, subject to the SELECT-specific restrictions below |
| INSERT / UPDATE / DELETE / DROP | block — run it yourself in a DB client |
| CREATE / ALTER | block |
| EXEC / EXECUTE / CALL | block — stored procedures cannot be run through this server |
Also blocked: multi-statement queries (semicolons outside `BEGIN..END`),
unterminated quotes or brackets, sequence advancement (`NEXTVAL` /
`NEXT VALUE FOR`), `OPENQUERY` / `OPENROWSET` / `OPENDATASOURCE`,
`SELECT ... INTO <table>`, and high-impact SQL Server lock hints (`UPDLOCK`,
`XLOCK`, `TABLOCK`, `TABLOCKX`, `HOLDLOCK`, `SERIALIZABLE`,
`REPEATABLEREAD`).
Service Layer requests allow only the verbs in the profile's `slAllowedMethods`
(default `GET` only); `PUT` is never available. PATCH and DELETE require
one directly keyed entity endpoint; POST accepts an entity set, a service
operation or one action on a keyed entity (`Orders(12)/Close`); writes take no
query options. Writes go through a separate tool, `execute_service_layer_write`,
so a client can gate them by tool name while GETs stay unprompted. Every write
names its target `database`, which must match the connected Service Layer.
By default every write requires explicit user acceptance through MCP form
elicitation. The approval screen is bound to the database, Service Layer root,
endpoint, exact body, field list and SHA-256 body hash. A client without
elicitation support cannot write. A profile with `slWriteApproval: "client"`
replaces the form with Claude Code's permission prompt, and one with
`slWriteApproval: "none"` also accepts unapproved writes through
`execute_service_layer_write_unattended` (see the profile fields above).
`MCP_DRY_RUN=true` never executes a write,
and `MCP_SL_WRITES_ENABLED=false` disables every write globally.
The per-table classification model (SAP_CORE / SAP_USER / CUSTOM / TEMP with
per-operation rules) still exists in `src/guardrails/rules/` and is fully
tested, but on the read-only server it is not reachable from the live path.
See [SECURITY.md](./SECURITY.md) for the full model and its known limitations.
## Agent skill
[`skills/b1-mcp-server/SKILL.md`](skills/b1-mcp-server/SKILL.md) teaches an AI agent how
to use these tools: connect first, what each refusal means and what to do about it, why a
HANA timeout reports a dropped socket, how truncated results look, and how Service Layer
writes are approved. To install it in Claude Code, copy the folder into
`~/.claude/skills/`; other agents that read `SKILL.md` files take it from their own skills
directory.
## Development
```bash
npm install
npm run build
npm run test
npm run test:watch
# Optional aliases (same behavior, useful if you prefer explicit naming):
npm run test:bash
npm run test:watch:bash
```
From a source checkout only, run this quick connection sanity check using a
profile from `connections.json` (`scripts/` is not shipped in the npm tarball):
```bash
npx tsx scripts/test-connection.ts <profile-id>
```
## License
MIT — see [LICENSE](./LICENSE).
TDQS
Scored across 7 tools
Read vs write tools are clearly separated (execute_service_layer vs execute_service_layer_write), and execute_sql/check_connection/get_schema_info target distinct concerns. The only real ambiguity is between execute_service_layer_write and execute_service_layer_write_unattended, which perform the identical operation and differ only in approval mode; the descriptions explain the distinction but an agent can still easily misselect.
All tools use snake_case with a consistent verb_noun pattern (execute_*, connect_*, check_*, get_*). The write/write_unattended pair follows a predictable suffix convention rather than breaking the pattern. Names are readable and self-describing throughout.
Seven tools is well-scoped for a DB/SAP integration server: connection management, status, schema introspection, read/write SQL, and read/write Service Layer. Each tool earns its place with no obvious filler or redundancy.
The surface covers connection lifecycle, schema exploration, status checks, and both read and write paths across SQL and Service Layer. The main intentional gap is direct SQL writes (blocked by design, handed to the user), and there is no explicit disconnect tool, but core workflows are covered.