dsp-mcp
by konrad-lange
README.md
# dsp-mcp
A local **MCP (Model Context Protocol)** server that exposes the 19 SAP Datasphere CLI
skills from [`dsp-cli`](https://github.com/FredericWall/dsp-cli) as MCP tools over
Streamable HTTP, so hosted assistants (like **SAP Joule**) can drive Datasphere modeling
by URL.
The server is a thin wrapper: each MCP tool call spawns the corresponding
`node --env-file=<dsp-cli>/.env skills/<name>/<name>.js …` child process and streams
its stdout back to the caller verbatim. All authentication, OAuth token caching, and
Datasphere logic stays in `dsp-cli`.
> **Disclaimer.** This is a personal, unofficial project. It is **not** an SAP
> product and is in no way endorsed, supported, or maintained by SAP. There is
> **no support** and **no warranty**: use it entirely at your own risk, and you
> are responsible for anything you do with it. See [LICENSE](LICENSE).
>
> **Attribution.** The 19 bundled Datasphere CLI skills are third-party code by
> Frederic Wall ([`dsp-cli`](https://github.com/FredericWall/dsp-cli), a fork of
> [`yuyonggang/dsp-cli`](https://github.com/yuyonggang/dsp-cli)), redistributed
> under their ISC license. This repository adds only the MCP wrapper around them.
> See [NOTICE](NOTICE) for full attribution, licenses, and trademarks.
## Quick start
```bash
# 1. Configure
cp .env.example .env
# edit .env: set DSP_CLI_DIR plus your Datasphere credentials
# (DATASPHERE_HOST, CLIENT_ID, CLIENT_SECRET, SPACE) — all required
# 2. Install
npm install
# 3. Run
npm run dev
```
You'll see a banner like:
```
[dsp-mcp] listening on http://127.0.0.1:3333/mcp
[dsp-mcp] health: http://127.0.0.1:3333/health
[dsp-mcp] tools: 19 registered
[dsp-mcp] dsp-cli dir: /Users/…/dsp-cli [ok]
[dsp-mcp] env file: /Users/…/dsp-cli/.env [ok]
[dsp-mcp] configure Joule with URL: http://127.0.0.1:3333/mcp
```
Point Joule (or any MCP client that speaks Streamable HTTP) at `http://127.0.0.1:3333/mcp`.
## Desktop app (menu bar)
Prefer not to babysit a terminal? A small macOS **menu bar app** (Electron) can
run the server for you — Start/Stop, Copy URL, Logs, and Preferences from a
status-bar icon, installable as a `.dmg`. It spawns this same server as a child
process (system Node required; the server code is unchanged). The 19 skills are
bundled inside the app, so users only enter their Datasphere credentials in
Preferences — no separate `dsp-cli` checkout needed (though an own `DSP_CLI_DIR`
can override the bundled skills).
```bash
cd desktop
npm install
npm run dev # develop against the live tray app
DSP_CLI_SRC=/path/to/dsp-cli npm run dist # build desktop/dist/dsp-mcp-<version>.dmg
```
See [`desktop/README.md`](desktop/README.md) for details. From the repo root you
can also use `npm run desktop:dev` / `npm run desktop:dist`.
## Cloud Foundry (SAP BTP)
The server also runs on Cloud Foundry so a hosted assistant can reach the MCP
endpoint over a public URL. The dsp-cli skills are bundled into this repo under
`cf/dsp-cli-src/` (source, committed) and vendored to `cf/vendor/dsp-cli/`
(generated by `heroku-postbuild`, git-ignored) — no separate dsp-cli checkout is
needed in the container.
```bash
# One-time: seed the OAuth token (from a prior local browser login) so the CLI
# authenticates headless. secrets.json holds access + refresh tokens.
B64=$(base64 -i ~/.@sap/datasphere-cli/.cache/secrets.json | tr -d '\n')
cf push
cf set-env dsp-mcp DATASPHERE_HOST "https://<tenant>.hcs.cloud.sap"
cf set-env dsp-mcp CLIENT_ID '<client-id>' # single-quote: XSUAA ids contain ! and |
cf set-env dsp-mcp CLIENT_SECRET '<client-secret>'
cf set-env dsp-mcp SPACE '<space>'
cf set-env dsp-mcp DSP_SECRETS_B64 "$B64"
cf restart dsp-mcp
```
`manifest.yml` sets `MCP_HOST=0.0.0.0`, `MCP_ALLOWED_HOSTS` (the route host, for the
SDK's DNS-rebinding guard), and `DSP_CLI_DIR=cf/vendor/dsp-cli`. Key notes:
- **Headless auth:** the CLI's normal login is an interactive browser flow. On CF,
`DSP_SECRETS_B64` is written into the CLI token cache at startup and
`DSP_SKIP_LOGIN` tells the skills to use it instead of logging in. The refresh
token is valid ~180 days — re-seed `DSP_SECRETS_B64` when it expires.
- **Endpoint auth:** the MCP endpoint itself is currently open (sandbox). Add an
auth layer before any non-sandbox use.
- **Secrets:** `DSP_SECRETS_B64` / `CLIENT_SECRET` are plaintext in the app env
here; for production use a credential/user-provided service instead.
## Smoke tests
```bash
# Health
curl -sS http://127.0.0.1:3333/health
# tools/list (JSON-RPC over Streamable HTTP)
curl -sS -X POST http://127.0.0.1:3333/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# initialize
curl -sS -X POST http://127.0.0.1:3333/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# call a read-only tool
curl -sS -X POST http://127.0.0.1:3333/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list-objects","arguments":{}}}'
```
Offline registry dump:
```bash
npm run list-tools # human-readable
npm run list-tools -- --json # machine-readable
```
## Configuration
All settings come from this project's own `.env` (see `.env.example`):
| Var | Default | Purpose |
| --- | --- | --- |
| `DSP_CLI_DIR` | *(required)* | Directory containing `skills/`. Locally, your dsp-cli checkout; on Cloud Foundry, the bundled copy `cf/vendor/dsp-cli` (see below). |
| `DATASPHERE_HOST` | *(required)* | Datasphere tenant URL, forwarded to each skill. |
| `CLIENT_ID` / `CLIENT_SECRET` | *(required)* | OAuth client credentials, forwarded to each skill. |
| `SPACE` | *(required)* | Default space ID used when `--space` is not passed. |
| `DSP_ENV_FILE` | *(unset)* | Local convenience only: if set, this file is loaded into the environment at startup (lets an existing `dsp-cli/.env` keep working). Leave unset on Cloud Foundry. |
| `MCP_HOST` | `127.0.0.1` | Bind address (localhost only for DNS-rebinding safety) |
| `MCP_PORT` | `3333` | Port |
| `SKILL_TIMEOUT_MS` | `300000` (5 min) | Per-skill hard timeout |
| `MCP_INCLUDE_STDERR` | `0` | When `1`, append child stderr to tool responses even on success |
The server reads `DATASPHERE_HOST`, `CLIENT_ID`, `CLIENT_SECRET`, and `SPACE` from its
own environment and passes them to each skill's child process (the skills read them
straight from `process.env`). This single source of truth works the same on a laptop
(dotenv-loaded `.env`) and on Cloud Foundry (platform env vars / a user-provided
service) — no on-disk credential file is required at runtime.
## Adding a skill
The registry is the only file you need to edit. Add one entry to `SKILLS` in
[`src/skills.ts`](src/skills.ts):
```ts
{
name: 'my-new-skill', // MCP tool name
description: 'One-line summary shown to the LLM.',
entry: entry('my-new-skill', 'my-new-skill.js'), // resolved from skillsDir
category: 'inspect',
mutates: false,
inputSchema: {
thing: z.string().describe('What to look at'),
...spaceField, // shared --space field
},
flags: {
thing: { flag: '--thing', kind: 'value' },
space: spaceFlag, // shared --space mapping
},
},
```
No other file changes needed — `src/index.ts` iterates `SKILLS` and registers each.
## Tool catalog
Nineteen tools, grouped by category (from `npm run list-tools`):
- **Create** — `create-local-table`, `create-view`, `create-analytic-model`, `create-model`, `create-data-flow`, `create-replication-flow`, `create-transformation-flow`
- **Inspect** — `list-objects`, `read-object`, `describe-model`, `impact-analysis`
- **Modify** — `add-columns-to-table`, `add-columns-to-view`, `rename-column`, `remove-column`
- **Cascade** — `propagate-columns`, `rename-column-cascade`, `remove-column-cascade`
- **Lifecycle** — `export-model`
## Troubleshooting
- **`skill env … [MISSING]` on startup** — one or more of `DATASPHERE_HOST`,
`CLIENT_ID`, `CLIENT_SECRET`, `SPACE` are not set. Add them to `.env` (see
`.env.example`), or on Cloud Foundry set them as platform env vars. The startup
warning names exactly which are missing.
- **`skill 'X' failed (exit 1): Missing required environment variables: DATASPHERE_HOST`**
— same problem: the server had no credentials to forward. Check the four vars above.
- **Tool call hangs then returns "timed out after …"** — bump `SKILL_TIMEOUT_MS`.
Some cascade operations on large graphs need more than 5 minutes.
- **`Host: evil.example` returns 4xx** — that's the SDK's DNS-rebinding protection.
Legitimate clients speaking `Host: 127.0.0.1` (or `localhost`) are accepted.
## Security
- Server binds to `127.0.0.1` only. Nothing outside your machine can reach it.
- SDK-provided Host-header validation blocks DNS-rebinding attacks against localhost.
- **No auth on the MCP endpoint itself**: anyone with a shell on this machine can call
mutating tools. Acceptable for a single-user dev laptop; do not run this on a shared
host.
> **⚠️ Do not expose this publicly without adding authentication.** The Cloud
> Foundry path above binds `0.0.0.0` behind a public router with **no auth on the
> MCP endpoint**. Anyone who finds the URL can call mutating tools against your
> Datasphere tenant. The deployment steps are a sandbox example only. Before any
> real use, put an auth layer in front of the endpoint and store credentials in a
> user-provided service, not plaintext env vars.
## Concurrency
Each tool call spawns a Node child. Concurrent modeling operations targeting the same
Datasphere object are a Datasphere-side race — the MCP server does not serialize them.
Rely on your caller (e.g. Joule) to sequence dependent operations.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues