Skip to main content
Glama
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.