@mgcrea/mcp-ovh-api
A Model Context Protocol server for the OVHcloud API,focused on Object Storage: buckets, objects, project users, S3 credentials and thestorage policies that tie them together.
The server is read-only by default. Mutating tools are not merely refused when writes areoff — they are never registered, so an agent cannot call them at all.
Features
- Curated tools over OVHcloud's
/1.0API with descriptions that spell out its traps (seeTraps worth knowing). - Read-only by default.
OVH_ALLOW_WRITES=1adds the write tools; the destructive onesthen additionally require an explicitconfirm: trueon every call. - All three OVH auth methods, picked automatically from whichever env vars are present:OAuth2 service account (recommended), application key + consumer key (SHA1-signed,with automatic clock-drift correction), or a static access token.
- Policy presets — including
write-only, which OVH's own role shortcut does not offer. - List results are summarized, and OVH's deprecated per-bucket
objects[]array (whichembeds every object in the bucket) is suppressed on both ends. X-Ovh-QueryIDis surfaced on every error, because that is the first thing OVH supportasks for.- An
ovh_requestescape hatch for the rest of the API (GET-only unless writes are enabled). - Native
fetch, no runtime dependencies beyond the MCP SDK and Zod.
Install
pnpm install
pnpm build
Configure
Pick one auth method.
(A) OAuth2 service account — recommended
- Create an IAM service account athttps://www.ovh.com/manager/#/iam/service-account.
- Attach an IAM policy granting it your public cloud project (for object storage:
publicCloudProject:apiovh:*on the project resource). - Copy the client id and secret into
.env.
Tokens last an hour and are cached and refreshed ahead of expiry.
(B) Application key + consumer key
Create the triplet in one shot at https://eu.api.ovh.com/createToken/. The access rulesyou list there are fixed forever — a consumer key cannot be widened afterwards, so grantwhat you need up front:
GET /cloud/project/*
POST /cloud/project/*
PUT /cloud/project/*
DELETE /cloud/project/*
GET /me
Requests are SHA1-signed over secret+consumerKey+METHOD+URL+BODY+TIMESTAMP. A clock morethan ~30s off OVH's fails every call with a misleading Invalid signature, so the serverprobes /auth/time once at startup and corrects for the delta.
(C) Static access token
Set OVH_ACCESS_TOKEN and it is sent as Authorization: Bearer.
cp .env.example .env
| Variable | Required | Description |
|---|---|---|
OVH_ENDPOINT |
no | ovh-eu (default), ovh-ca, ovh-us, kimsufi-*, soyoustart-*. |
OVH_CLIENT_ID / OVH_CLIENT_SECRET |
(A) | IAM service account. Their presence selects OAuth2. |
OVH_APPLICATION_KEY / _SECRET |
(B) | Application key pair. |
OVH_CONSUMER_KEY |
(B) | Consumer key issued alongside them. |
OVH_ACCESS_TOKEN |
(C) | Pre-minted bearer token. |
OVH_AUTH_METHOD |
no | Force oauth2, signature or accessToken. Otherwise inferred. |
OVH_CLOUD_PROJECT |
no | Default project — the 32-char hex serviceName, not the display name. |
OVH_REGION |
no | Default storage region, upper-case (GRA, SBG, DE, UK). |
OVH_ALLOW_WRITES |
no | Set to 1 to register the write tools. Off by default. |
OVH_API_URL |
no | Override the API base URL entirely. |
OVH_MAX_RETRIES |
no | Retry budget for 401 / 429 / 5xx. Defaults to 3. |
OVH_REFRESH_SKEW_SECONDS |
no | Refresh the OAuth2 token this long before expiry. Defaults to 60. |
OVH_DEBUG |
no | Set to 1 to log debug output to stderr. |
Run
pnpm start # speaks JSON-RPC over stdio
Wire into Claude Code
Add to .mcp.json (project) or ~/.claude.json (global):
{
"mcpServers": {
"ovh": {
"command": "node",
"args": ["/absolute/path/to/mcp-ovh-api/dist/cli.js"],
"env": {
"OVH_CLIENT_ID": "...",
"OVH_CLIENT_SECRET": "...",
"OVH_CLOUD_PROJECT": "abcdef0123456789abcdef0123456789",
"OVH_REGION": "UK"
}
}
}
}
Inspect the tools
npx @modelcontextprotocol/inspector node dist/cli.js
Traps worth knowing
All are baked into the tool descriptions, but they explain the shape of this server:
OVH has no bucket policies — only user policies. One raw JSON document per projectuser, and that document is the entire access-control surface. Setting a policy replaceseverything that user could previously do, across all buckets.
A policy cannot restrict the bucket's owner. OVH falls back to ACLs and the ownerholds
FULL_CONTROL: "if the user is the bucket owner and even if there is no explicitallow in the policy file, the user will be authorized." A restricted key must thereforebelong to a new project user that did not create the bucket.ovh_provision_s3_userchecks the bucket'sownerIdand refuses when you point it at the owner.The same fallback applies per object. Whoever uploads an object owns it and gets
FULL_CONTROLon it through the object ACL. So merely omittings3:GetObjectdoesnot stop an upload-only key from reading back everything it wrote — verified againstthe live API, where a bare allow-list policy happily served the key its own uploads whilecorrectly denying every object someone else had uploaded. An explicitDenyisrequired, and it does beat the ACL. That is why thewrite-onlypreset ships aDenystatement rather than a bare allow-list.
Two smaller ones. s3:PutObject alone still permits blind overwrite of existing keysinside the allowed prefix — a "write-only" key is not an append-only key, which is a goodreason to enable versioning on the bucket. And policy changes take up to ~30 seconds topropagate: a probe run five seconds after ovh_set_storage_policy still shows the oldbehaviour, which reads exactly like a policy that silently failed.
Tools
Every project-scoped tool takes an optional project, and every storage tool an optionalregion, overriding OVH_CLOUD_PROJECT / OVH_REGION per call. Tools marked W existonly when OVH_ALLOW_WRITES=1; those marked ⚠️ are destructive and additionally requireconfirm: true.
Start with ovh_whoami. It reports which auth method is live, which account you are, andthe clock delta against OVH — which is what a 401 on the signature method is nearly alwaysabout.
| Area | Tools |
|---|---|
| Meta | ovh_whoami, ovh_list_projects, ovh_get_project, ovh_list_regions, ovh_get_region |
| Buckets | ovh_list_buckets, ovh_get_bucket, ovh_get_bucket_lifecycle · W ovh_create_bucket, ovh_update_bucket, ovh_set_bucket_lifecycle, ⚠️ ovh_delete_bucket_lifecycle, ⚠️ ovh_delete_bucket |
| Objects | ovh_list_objects, ovh_get_object, ovh_list_object_versions, ovh_presign_object · W ovh_copy_object, ⚠️ ovh_delete_object, ⚠️ ovh_delete_object_version, ⚠️ ovh_bulk_delete_objects |
| Users & keys | ovh_list_project_users, ovh_get_project_user, ovh_list_s3_credentials · W ovh_create_project_user, ovh_create_s3_credentials, ovh_reveal_s3_secret, ⚠️ ovh_delete_s3_credentials, ⚠️ ovh_delete_project_user |
| Policies | ovh_get_storage_policy, ovh_preview_policy · W ⚠️ ovh_set_storage_policy, ⚠️ ovh_grant_bucket_access, ⚠️ ovh_provision_s3_user |
| Escape hatch | ovh_request — any /1.0 path, GET-only unless writes are enabled |
ovh_presign_object is the only way bytes move: the server never proxies object content, itmints a time-limited presigned S3 URL instead. With writes off it signs GET only.
Policy presets
ovh_preview_policy, ovh_set_storage_policy and ovh_provision_s3_user share threepresets, all scopable to a key prefix:
| Preset | Grants |
|---|---|
write-only |
Allow s3:PutObject, s3:AbortMultipartUpload, s3:ListMultipartUploadParts on the prefix — plus an explicit Deny on s3:GetObject / s3:GetObjectAcl bucket-wide |
read-only |
s3:ListBucket + s3:GetBucketLocation on the bucket, s3:GetObject on the objects |
read-write |
both, plus s3:DeleteObject |
OVH's built-in roles (admin, deny, readOnly, readWrite, via ovh_grant_bucket_access)have no write-only equivalent — that is why the raw-policy path exists. The multipart pair isincluded deliberately: every S3 SDK auto-switches to multipart above ~8-16MB, and withoutabort/list a failed upload orphans parts the key holder cannot clean up and keeps paying for.
OVH validates policy actions against a fixed enum and rejects the whole document with a400 if one is unknown — s3:GetObjectVersion and s3:DeleteObjectVersion exist in AWS butnot there. The presets use only accepted actions, and a test pins that.
Handing out a write-only upload key
The motivating case: an app embeds an S3 key in a shipped binary, so the key must be able toupload and nothing else, while the read/write key stays with the developer.
ovh_get_bucket bucket=dev-rgis-ar → note ownerId
ovh_preview_policy bucket=dev-rgis-ar preset=write-only prefix=uploads/
ovh_provision_s3_user bucket=dev-rgis-ar preset=write-only prefix=uploads/ \
description=ar-app-uploader confirm=true
That creates a new project user (never the bucket owner), applies the policy, and only thenmints credentials — a key that exists before its policy is a key that briefly had whatever thedefault allows. The secret is returned once.
Verify against the real S3 API before handing it over — a policy that reads correctly canstill be shadowed by ownership, and wait ~30s after setting it or you will be probing theprevious policy:
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
# An array, not a string: zsh does not word-split an unquoted $var, so the
# `S3='aws ...'` form you would write in bash silently becomes "command not found".
S3=(aws --endpoint-url https://s3.uk.io.cloud.ovh.net --region uk s3api)
"${S3[@]}" put-object --bucket dev-rgis-ar --key uploads/probe.txt --body /dev/null # 200
"${S3[@]}" get-object --bucket dev-rgis-ar --key uploads/probe.txt /dev/null # 403
"${S3[@]}" list-objects-v2 --bucket dev-rgis-ar # 403
"${S3[@]}" delete-object --bucket dev-rgis-ar --key uploads/probe.txt # 403
"${S3[@]}" put-object --bucket dev-rgis-ar --key elsewhere/probe.txt --body /dev/null # 403
The get-object line is the one that matters: it is the check that catches trap 3, and itpasses only because of the preset's Deny.
Develop
pnpm dev # tsdown --watch
pnpm test # vitest
pnpm typecheck
pnpm lint
pnpm format
License
MIT