Give the models object storage, chosen by the flow and not by them
minio-mcp offers bounded list, read, stat and write over MinIO, in two modes the session is opened in - which, in the workflow manager, is the catalog entry the flow author picked. The model never chooses between them. Internal: the session arrives with x-minio-scope set to the execution's id. The server uses the platform's MinIO from its own environment and, at initialize, creates the bucket <prefix><execution id> when it is missing - the counterpart of the coding agent creating an execution's workspace directory. The model sees four tools and keys; no endpoint, no credential, no session to open. The prefix (exec- by default) cannot be emptied. It is what keeps a caller holding the API key inside the buckets this server made, rather than free to name any bucket on that MinIO - the storage nodes' among them. The scope header is pinned for the session, as dev-server's preview key is, and a session whose bucket cannot be had never opens. External: no header. The model opens sessions with endpoint, bucket and credentials itself, as before, limited by MINIO_MCP_ALLOWED_ENDPOINTS. Wired into the stack behind /minio/ and :3104. No delete tool in either mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1eedcb8964
commit
be5690309f
21
MCP-STACK.md
21
MCP-STACK.md
|
|
@ -1,11 +1,12 @@
|
|||
# Secure MCP stack
|
||||
|
||||
This workspace contains three independent Streamable HTTP MCP servers:
|
||||
This workspace contains four independent Streamable HTTP MCP servers:
|
||||
|
||||
- `coding-agent-mcp`: scoped workspace editing. Task execution is disabled in this stack.
|
||||
- `dev-server-mcp`: authenticated facade for a private, allowlisted development-server worker. One instance per execution, each in a throwaway copy of that execution's tree and on its own port.
|
||||
- `egress-proxy`: the only container with a route to the internet. It allows CONNECT to the package registries and refuses everything else, which is how the worker installs dependencies without being on the network itself.
|
||||
- `egress-proxy`: the development worker's only route to package registries; it allows their CONNECT requests and refuses everything else.
|
||||
- `browser-mcp`: Playwright automation restricted to exact allowed origins.
|
||||
- `minio-mcp`: bounded list/read/stat/write access to object storage, with no delete tool. In internal mode (catalog entry `minio-mcp-internal`, header `x-minio-scope`) it uses the platform's MinIO from `MINIO_MCP_INTERNAL_*` and one bucket per execution, `<MINIO_MCP_INTERNAL_BUCKET_PREFIX><execution id>`, created when the session opens; in external mode the model opens session-scoped connections to MinIO deployments itself.
|
||||
|
||||
The MCP client orchestrates them; servers do not share MCP sessions or credentials.
|
||||
|
||||
|
|
@ -15,13 +16,19 @@ The MCP client orchestrates them; servers do not share MCP sessions or credentia
|
|||
2. Set `MCP_WORKSPACE_HOST_PATH` to one dedicated project directory.
|
||||
3. Copy and edit `dev-server-mcp/services.example.json`. It ships four worked service definitions: a shared Vite tree, and per-execution ones for Node, Java (through the project's own `./mvnw`) and Python.
|
||||
Keep `BROWSER_MCP_ALLOWED_ORIGINS` aligned with `DEV_SERVER_PORT_RANGE`: the browser reaches a preview only on a port that range covers.
|
||||
4. Run `docker compose --env-file .env -f mcp-stack.compose.yml up --build`.
|
||||
4. Optionally set `MINIO_MCP_ALLOWED_ENDPOINTS` to the comma-separated MinIO S3 origins clients may use.
|
||||
5. Run `docker compose --env-file .env -f mcp-stack.compose.yml up --build`.
|
||||
|
||||
Local MCP endpoints:
|
||||
|
||||
- `http://127.0.0.1:3101/mcp` — coding agent
|
||||
- `http://127.0.0.1:3102/mcp` — development server
|
||||
- `http://127.0.0.1:3103/mcp` — browser
|
||||
- `http://127.0.0.1:3104/mcp` — MinIO
|
||||
|
||||
The same endpoints are also available through the unified gateway at `/coding-agent/mcp`,
|
||||
`/dev-server/mcp`, `/browser/mcp` and `/minio/mcp` on port 3100 (or the configured HTTPS host on
|
||||
the VM deployment).
|
||||
|
||||
Example Codex client configuration (`.codex/config.toml` in a trusted project):
|
||||
|
||||
|
|
@ -37,11 +44,15 @@ bearer_token_env_var = "DEV_SERVER_MCP_TOKEN"
|
|||
[mcp_servers.browser]
|
||||
url = "http://127.0.0.1:3103/mcp"
|
||||
bearer_token_env_var = "BROWSER_MCP_TOKEN"
|
||||
|
||||
[mcp_servers.minio]
|
||||
url = "http://127.0.0.1:3104/mcp"
|
||||
bearer_token_env_var = "MINIO_MCP_TOKEN"
|
||||
```
|
||||
|
||||
Set those three variables in the MCP client's environment to the token portions configured for the corresponding servers. Do not reuse a token between servers.
|
||||
Set those four variables in the MCP client's environment to the token portions configured for the corresponding servers. Do not reuse a token between servers.
|
||||
|
||||
Only the minimal Caddy gateway publishes loopback ports. The MCP and worker containers stay exclusively on internal networks and therefore have no general Internet egress. For remote use, replace or front this local gateway with a TLS reverse proxy and rate limiting.
|
||||
Only the minimal Caddy gateway publishes loopback ports. The coding, development and browser services stay exclusively on internal networks. `minio-mcp` has outbound connectivity because each MCP session can open connections to remote S3 endpoints. Set `MINIO_MCP_ALLOWED_ENDPOINTS` whenever those endpoints are known; leaving it empty gives authenticated MCP clients an intentional network-request capability. For remote use, use the included VM overlay so Caddy terminates TLS, and add rate limiting at the network edge when appropriate.
|
||||
|
||||
The worker and browser networks are marked internal, no service uses host networking or the Docker socket, and the development-server workspace mount is read-only. Container isolation reduces the blast radius but is not a substitute for an ephemeral VM/microVM when executing hostile multi-tenant code.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
# The one door a person opens, as opposed to the three above, which a machine opens with an API
|
||||
handle_path /minio/* {
|
||||
reverse_proxy minio-mcp:3000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# The one door a person opens, as opposed to the MCP routes above, which a machine opens with an API
|
||||
# key. handle_path strips the prefix, so the worker's proxy sees /<execution-key>/... and the
|
||||
# application behind it sees the path it would see at the root.
|
||||
#
|
||||
|
|
@ -60,3 +66,9 @@
|
|||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
:3104 {
|
||||
reverse_proxy minio-mcp:3000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,43 @@ services:
|
|||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
minio-mcp:
|
||||
build:
|
||||
context: ./minio-mcp
|
||||
environment:
|
||||
MINIO_MCP_API_KEYS: ${MINIO_MCP_API_KEYS}
|
||||
MINIO_MCP_DEFAULT_REGION: ${MINIO_MCP_DEFAULT_REGION:-us-east-1}
|
||||
MINIO_MCP_MAX_OBJECT_BYTES: ${MINIO_MCP_MAX_OBJECT_BYTES:-1048576}
|
||||
MINIO_MCP_MAX_CONNECTIONS_PER_SESSION: ${MINIO_MCP_MAX_CONNECTIONS_PER_SESSION:-8}
|
||||
# Optional comma-separated origins. Empty permits any HTTP(S) MinIO origin; restrict this
|
||||
# when the set of deployments is known, because every allowed MCP client can open sessions.
|
||||
MINIO_MCP_ALLOWED_ENDPOINTS: ${MINIO_MCP_ALLOWED_ENDPOINTS:-}
|
||||
# The platform's own MinIO, for sessions the catalog opens with x-minio-scope: one bucket
|
||||
# per execution, <prefix><execution id>, created on first use. Leave all three empty to offer only the external
|
||||
# mode. The endpoint is reached over minio-egress, so it has to be routable from there.
|
||||
MINIO_MCP_INTERNAL_ENDPOINT: ${MINIO_MCP_INTERNAL_ENDPOINT:-}
|
||||
MINIO_MCP_INTERNAL_ACCESS_KEY: ${MINIO_MCP_INTERNAL_ACCESS_KEY:-}
|
||||
MINIO_MCP_INTERNAL_SECRET_KEY: ${MINIO_MCP_INTERNAL_SECRET_KEY:-}
|
||||
MINIO_MCP_INTERNAL_REGION: ${MINIO_MCP_INTERNAL_REGION:-}
|
||||
MINIO_MCP_INTERNAL_BUCKET_PREFIX: ${MINIO_MCP_INTERNAL_BUCKET_PREFIX:-exec-}
|
||||
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=16m
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
pids_limit: 64
|
||||
mem_limit: 256m
|
||||
cpus: 0.5
|
||||
init: true
|
||||
expose: ["3000"]
|
||||
# minio-control is the private path from Caddy. minio-egress is needed because each logical
|
||||
# session may point at a different remote MinIO S3 endpoint, and the internal one is reached
|
||||
# the same way.
|
||||
networks: [minio-control, minio-egress]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
mcp-gateway:
|
||||
image: caddy:2
|
||||
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
|
||||
|
|
@ -219,7 +256,7 @@ services:
|
|||
MCP_TLS_CONTACT: ${MCP_TLS_CONTACT:-}
|
||||
ports:
|
||||
# One port serving every server by path, which is the shape the workflow manager's catalog
|
||||
# uses. The three below stay for clients configured before it existed.
|
||||
# uses. The dedicated ports below stay for clients configured before it existed.
|
||||
#
|
||||
# Loopback, always. These are the servers without TLS in front of them; the VM overlay adds
|
||||
# 443 beside them rather than replacing them, and compose merges port lists by appending -
|
||||
|
|
@ -228,8 +265,9 @@ services:
|
|||
- "127.0.0.1:${CODING_AGENT_MCP_PORT:-3101}:3101"
|
||||
- "127.0.0.1:${DEV_SERVER_MCP_PORT:-3102}:3102"
|
||||
- "127.0.0.1:${BROWSER_MCP_PORT:-3103}:3103"
|
||||
networks: [gateway, coding-control, dev-server-control, workspace-browser]
|
||||
depends_on: [coding-agent-mcp, dev-server-mcp, browser-mcp]
|
||||
- "127.0.0.1:${MINIO_MCP_PORT:-3104}:3104"
|
||||
networks: [gateway, coding-control, dev-server-control, workspace-browser, minio-control]
|
||||
depends_on: [coding-agent-mcp, dev-server-mcp, browser-mcp, minio-mcp]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
|
|
@ -241,6 +279,9 @@ networks:
|
|||
internal: true
|
||||
workspace-browser:
|
||||
internal: true
|
||||
minio-control:
|
||||
internal: true
|
||||
minio-egress:
|
||||
# The worker and the proxy meet here, and nothing else does. Internal, so joining it grants no
|
||||
# route out: the proxy's own egress comes from the separate network below, which only it joins.
|
||||
proxy-control:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,34 @@
|
|||
CODING_AGENT_MCP_API_KEYS=agent=replace-with-at-least-32-random-characters
|
||||
DEV_SERVER_MCP_API_KEYS=agent=replace-with-a-different-32-char-token
|
||||
BROWSER_MCP_API_KEYS=agent=replace-with-a-third-32-char-token
|
||||
MINIO_MCP_API_KEYS=agent=replace-with-a-fourth-32-char-token
|
||||
DEV_SERVER_WORKER_TOKEN=replace-with-a-private-worker-token-32-chars
|
||||
|
||||
# External mode: URL, bucket and credentials are passed once to minio_open_session, not configured
|
||||
# on this long-running container. It can therefore hold sessions to different MinIO installations.
|
||||
MINIO_MCP_DEFAULT_REGION=us-east-1
|
||||
MINIO_MCP_MAX_CONNECTIONS_PER_SESSION=8
|
||||
# Optional comma-separated S3 endpoint origins. Leave empty only when every MCP client is trusted
|
||||
# to make this server connect to arbitrary HTTP(S) destinations.
|
||||
MINIO_MCP_ALLOWED_ENDPOINTS=
|
||||
# Applies independently to reads and writes. Base64 overhead is handled by the HTTP body limit.
|
||||
MINIO_MCP_MAX_OBJECT_BYTES=1048576
|
||||
|
||||
# The internal mode, chosen by the catalog entry minio-mcp-internal rather than by the model: the
|
||||
# session arrives with x-minio-scope set to the execution's id, and this server uses the
|
||||
# platform's MinIO below, creating the bucket <prefix><execution id> on first use. These
|
||||
# credentials never reach the model. They must be allowed to create buckets; leave all three empty
|
||||
# to disable the mode.
|
||||
MINIO_MCP_INTERNAL_ENDPOINT=
|
||||
MINIO_MCP_INTERNAL_ACCESS_KEY=
|
||||
MINIO_MCP_INTERNAL_SECRET_KEY=
|
||||
# Defaults to MINIO_MCP_DEFAULT_REGION.
|
||||
MINIO_MCP_INTERNAL_REGION=
|
||||
# Every internal bucket starts with this, so the API key reaches only buckets this server made -
|
||||
# never the storage nodes' or any other bucket on that MinIO. Cannot be empty; up to 27 characters
|
||||
# of lowercase letters, digits and '-'.
|
||||
MINIO_MCP_INTERNAL_BUCKET_PREFIX=exec-
|
||||
|
||||
# Use a dedicated project directory, never a home directory or filesystem root.
|
||||
MCP_WORKSPACE_HOST_PATH=/absolute/path/to/project
|
||||
# Whoever owns the workspace directory on the host. This file is read literally - no shell runs
|
||||
|
|
@ -30,6 +56,7 @@ BROWSER_MCP_ALLOWED_ORIGINS=http://dev-server-worker:5173,http://dev-server-work
|
|||
CODING_AGENT_MCP_PORT=3101
|
||||
DEV_SERVER_MCP_PORT=3102
|
||||
BROWSER_MCP_PORT=3103
|
||||
MINIO_MCP_PORT=3104
|
||||
|
||||
# The gateway publishes every server under one host by path, which is what the catalog expects.
|
||||
MCP_GATEWAY_PORT=3100
|
||||
|
|
|
|||
|
|
@ -40,7 +40,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
# The one door a person opens, as opposed to the three above, which a machine opens with an API
|
||||
handle_path /minio/* {
|
||||
reverse_proxy minio-mcp:3000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# The one door a person opens, as opposed to the MCP routes above, which a machine opens with an API
|
||||
# key. handle_path strips the prefix, so the worker's proxy sees /<execution-key>/... and the
|
||||
# application behind it sees the path it would see at the root.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
test
|
||||
.git
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
npm-debug.log*
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOME=/tmp/minio-mcp-home
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force \
|
||||
&& groupadd --gid 10001 mcp \
|
||||
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcp \
|
||||
&& mkdir -p "$HOME" \
|
||||
&& chown -R mcp:mcp /app "$HOME"
|
||||
|
||||
COPY --chown=mcp:mcp src ./src
|
||||
|
||||
USER mcp
|
||||
CMD ["node", "src/index.js"]
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# MinIO MCP
|
||||
|
||||
Authenticated Streamable HTTP MCP server with two modes, chosen per MCP session by whoever opens it
|
||||
- in the workflow manager, by which catalog entry the flow author picked - and never by the model:
|
||||
|
||||
- **Internal** (`minio-mcp-internal`): the session is opened with an `x-minio-scope` header naming
|
||||
the execution. The server uses the platform's own MinIO from its environment and creates the
|
||||
bucket `<prefix><execution id>` when the session opens, the way the coding agent creates an execution's workspace
|
||||
directory. The model only lists, reads and writes keys; it sees no endpoint and no credential.
|
||||
- **External** (`minio-mcp`): no header. The model opens a MinIO session with endpoint, bucket and
|
||||
credentials, then uses the returned opaque ID for object operations, and may hold several.
|
||||
|
||||
The server has no delete tool in either mode.
|
||||
|
||||
MinIO calls credentials an **access key** and **secret key**. Use a dedicated MinIO service account
|
||||
with permissions limited to the configured bucket, not the root account.
|
||||
|
||||
## Configuration
|
||||
|
||||
Required server variable:
|
||||
|
||||
- `MINIO_MCP_API_KEYS`: comma-separated MCP bearer tokens, optionally `subject=token`; each token must contain at least 32 characters.
|
||||
|
||||
Optional variables:
|
||||
|
||||
- `MINIO_MCP_DEFAULT_REGION` (default `us-east-1`). A caller may override it when opening a session.
|
||||
- `MINIO_MCP_MAX_CONNECTIONS_PER_SESSION` (default 8, maximum 64).
|
||||
- `MINIO_MCP_ALLOWED_ENDPOINTS`: comma-separated HTTP(S) origins clients may connect to. Empty permits every origin.
|
||||
- `MINIO_MCP_MAX_OBJECT_BYTES` (default 1 MiB, hard configuration ceiling 32 MiB).
|
||||
- `MINIO_MCP_REQUEST_MAX_BYTES`, `MINIO_MCP_MAX_SESSIONS`, `MINIO_MCP_SESSION_TTL_SECONDS`.
|
||||
- `MINIO_MCP_HOST` (default `0.0.0.0`), `MINIO_MCP_PORT` (default `3000`) and `MINIO_MCP_CORS_ORIGINS`.
|
||||
|
||||
Internal mode, all three or none (none disables the mode):
|
||||
|
||||
- `MINIO_MCP_INTERNAL_ENDPOINT`, `MINIO_MCP_INTERNAL_ACCESS_KEY`, `MINIO_MCP_INTERNAL_SECRET_KEY`.
|
||||
- `MINIO_MCP_INTERNAL_REGION` (defaults to `MINIO_MCP_DEFAULT_REGION`).
|
||||
- `MINIO_MCP_INTERNAL_BUCKET_PREFIX` (default `exec-`). Not optional: it cannot be set empty, and it
|
||||
is up to 27 lowercase letters, digits or `-`, leaving room for a 36-character execution id.
|
||||
|
||||
The bucket is always the prefix followed by the header's value, so a caller holding the API key
|
||||
reaches only buckets this server created - never the storage nodes' or any other bucket on that
|
||||
MinIO. Granting the internal credentials only `exec-*` buckets in a MinIO policy makes the same
|
||||
boundary hold on the MinIO side too.
|
||||
|
||||
The internal credentials must be allowed to create buckets. A session opened with `x-minio-scope`
|
||||
while the mode is not configured, or with a value that does not make a valid bucket name behind the
|
||||
prefix (lowercase letters, digits and `-`, at most 63 characters in all), is refused at
|
||||
`initialize`. The header is fixed for the life of
|
||||
the session: a request carrying a different value gets 400.
|
||||
|
||||
In external mode, endpoint, bucket, access key and secret key are arguments to
|
||||
`minio_open_session`, not environment variables of the long-running server. The endpoint must be an HTTP(S) origin with no embedded
|
||||
credentials or path. Prefer HTTPS unless it is reachable only over a private container network.
|
||||
|
||||
## Tools
|
||||
|
||||
An internal session lists only `minio_list_objects`, `minio_get_object`, `minio_stat_object` and
|
||||
`minio_put_object`, without a `minioSessionId`: its bucket was chosen when the session opened. An
|
||||
external session lists:
|
||||
|
||||
- `minio_open_session`
|
||||
- `minio_close_session`
|
||||
- `minio_list_objects`
|
||||
- `minio_get_object` (`utf8` or `base64`)
|
||||
- `minio_stat_object`
|
||||
- `minio_put_object` (UTF-8 text or base64; overwrites an existing key)
|
||||
|
||||
`minio_open_session` verifies the endpoint and bucket, then returns `minioSessionId`. Every other
|
||||
tool takes that ID instead of the credentials. IDs are isolated by MCP session: another client
|
||||
cannot reuse one. Credentials remain only in server memory and are discarded by
|
||||
`minio_close_session`, when the MCP session expires or closes, and when the server shuts down.
|
||||
|
||||
All reads and writes are bounded by `MINIO_MCP_MAX_OBJECT_BYTES`. Calls are audit logged without
|
||||
arguments or object contents, so keys, data and credentials are not written to the application log.
|
||||
The system invoking `minio_open_session` must likewise avoid persisting its input arguments.
|
||||
|
||||
Because the endpoint is dynamic, an authenticated MCP client can make this server initiate network
|
||||
requests. Set `MINIO_MCP_ALLOWED_ENDPOINTS` when the valid destinations are known, and apply egress
|
||||
firewall rules when they are not. The allowlist contains exact origins, including a non-default port:
|
||||
|
||||
```env
|
||||
MINIO_MCP_ALLOWED_ENDPOINTS=https://s3.one.example.org,http://minio.internal:9000
|
||||
```
|
||||
|
||||
For a bucket named `agent-data`, a minimal MinIO policy is:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": ["arn:aws:s3:::agent-data"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::agent-data/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace both occurrences of `agent-data` when using another bucket. `minio_stat_object` uses the
|
||||
same `s3:GetObject` permission as a read.
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
MINIO_MCP_API_KEYS=agent=a-random-token-with-at-least-32-characters \
|
||||
npm start
|
||||
```
|
||||
|
||||
The MCP endpoint is `http://127.0.0.1:3000/mcp`. After the MCP protocol session is initialized,
|
||||
open a MinIO session with:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://s3.example.org",
|
||||
"bucket": "agent-data",
|
||||
"accessKey": "ACCESS_KEY",
|
||||
"secretKey": "SECRET_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
Pass the returned `minioSessionId` to list, get, stat and put calls. One MCP session may open
|
||||
multiple MinIO sessions and switch between them by ID. Run tests with `npm test`.
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
{
|
||||
"name": "minio-mcp",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "minio-mcp",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1144.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1001.1",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
|
||||
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1144.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1144.0.tgz",
|
||||
"integrity": "sha512-CAicFlKAuCAUEU1tIptCTh6Eikr4rig9CWYwofZdEuJm9i24XNclgCspV4DKXlOY2nK2AMGHRt88oxmwDdNW7w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1001.1",
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.84",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.978.1",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
|
||||
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@aws-sdk/xml-builder": "^3.972.41",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/signature-v4": "^5.7.3",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
|
||||
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.74",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
|
||||
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.973.17",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
|
||||
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.79",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.79",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
|
||||
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.84",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
|
||||
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.17",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
|
||||
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.973.16",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
|
||||
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/token-providers": "3.1138.0",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.78",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
|
||||
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.77",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
|
||||
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.46",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
|
||||
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.47",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
|
||||
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/signature-v4": "^5.7.3",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1138.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
|
||||
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.974.6",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
|
||||
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.41",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
|
||||
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz",
|
||||
"integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
|
||||
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.2",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
|
||||
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
|
||||
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
|
||||
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
|
||||
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "minio-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1144.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { MinioService, createS3Client, normalizeEndpoint } from "./minio-service.js";
|
||||
|
||||
/**
|
||||
* The header that puts a session on the internal storage: its value names the scope - normally the
|
||||
* execution's id - and the bucket is that value behind the server's prefix. Set by the catalog entry
|
||||
* the flow author picked, never by the model.
|
||||
*/
|
||||
export const INTERNAL_SCOPE_HEADER = "x-minio-scope";
|
||||
|
||||
/**
|
||||
* Every internal bucket starts with this, and the prefix is not optional: it is what keeps a caller
|
||||
* holding the API key inside the buckets this server made, rather than free to name any bucket on
|
||||
* the platform's MinIO - the storage nodes' among them.
|
||||
*/
|
||||
export const DEFAULT_INTERNAL_BUCKET_PREFIX = "exec-";
|
||||
|
||||
// Room for a 36-character execution id within S3's 63.
|
||||
const MAX_BUCKET_PREFIX_LENGTH = 27;
|
||||
const BUCKET_PREFIX = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const SCOPE = /^[a-z0-9-]+$/;
|
||||
// S3 bucket naming, minus dots.
|
||||
const BUCKET_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
|
||||
|
||||
const INTERNAL_OPERATIONS = new Set(["listObjects", "getObject", "statObject", "putObject"]);
|
||||
|
||||
function clientError(message) {
|
||||
return Object.assign(new Error(message), { statusCode: 400 });
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope a session asked for, or null when it did not ask for the internal storage. A header
|
||||
* sent empty still counts as asking - an unresolved template, say - so it fails validation rather
|
||||
* than quietly opening an external session.
|
||||
*/
|
||||
export function internalScopeOf(context) {
|
||||
const value = context?.pinned?.[INTERNAL_SCOPE_HEADER];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function validBucketPrefix(prefix) {
|
||||
if (typeof prefix !== "string" || !BUCKET_PREFIX.test(prefix) || prefix.length > MAX_BUCKET_PREFIX_LENGTH) {
|
||||
throw new Error(`The internal bucket prefix must be 1-${MAX_BUCKET_PREFIX_LENGTH} lowercase letters, digits or '-', starting with a letter or digit`);
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function assertArguments(args) {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("Arguments must be an object");
|
||||
}
|
||||
|
||||
function rejectUnknown(args, allowed) {
|
||||
const unknown = Object.keys(args).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length > 0) throw new Error(`Unknown argument${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
function mcpSessionId(context) {
|
||||
if (!context || typeof context.sessionId !== "string" || context.sessionId.length === 0) {
|
||||
throw new Error("An active MCP session is required");
|
||||
}
|
||||
return context.sessionId;
|
||||
}
|
||||
|
||||
export class MinioConnectionRegistry {
|
||||
constructor({
|
||||
maxConnectionsPerSession = 8,
|
||||
maxObjectBytes = 1024 * 1024,
|
||||
defaultRegion = "us-east-1",
|
||||
allowedEndpoints = [],
|
||||
// { endpoint, accessKey, secretKey, region, bucketPrefix } of the platform's own MinIO, or null
|
||||
// when this server offers only sessions the model opens itself.
|
||||
internal = null,
|
||||
serviceFactory,
|
||||
clientFactory = createS3Client
|
||||
} = {}) {
|
||||
if (!Number.isInteger(maxConnectionsPerSession) || maxConnectionsPerSession < 1) {
|
||||
throw new Error("maxConnectionsPerSession must be a positive integer");
|
||||
}
|
||||
this.maxConnectionsPerSession = maxConnectionsPerSession;
|
||||
this.maxObjectBytes = maxObjectBytes;
|
||||
this.defaultRegion = defaultRegion;
|
||||
this.allowedEndpoints = new Set(allowedEndpoints.map(normalizeEndpoint));
|
||||
this.serviceFactory = serviceFactory ?? ((options) => new MinioService(options));
|
||||
this.connections = new Map();
|
||||
this.internal = internal ? {
|
||||
...internal,
|
||||
region: internal.region || defaultRegion,
|
||||
bucketPrefix: validBucketPrefix(internal.bucketPrefix ?? DEFAULT_INTERNAL_BUCKET_PREFIX)
|
||||
} : null;
|
||||
// One client for every execution's bucket: the credentials are the same, only the bucket varies.
|
||||
this.internalClient = this.internal ? clientFactory(this.internal) : null;
|
||||
this.internalSessions = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds an MCP session to its execution's bucket on the internal MinIO - the server's prefix
|
||||
* followed by the scope - creating the bucket when it does not exist yet. Runs at session open, so
|
||||
* a session whose bucket cannot be had never opens.
|
||||
*/
|
||||
async openInternal(context) {
|
||||
const owner = mcpSessionId(context);
|
||||
const scope = internalScopeOf(context);
|
||||
if (scope === null) throw clientError(`${INTERNAL_SCOPE_HEADER} is required for the internal storage`);
|
||||
if (!this.internal) throw clientError("The internal MinIO storage is not configured on this server");
|
||||
const bucket = this.internal.bucketPrefix + scope;
|
||||
if (!SCOPE.test(scope) || !BUCKET_NAME.test(bucket)) {
|
||||
throw clientError(`${INTERNAL_SCOPE_HEADER} must be lowercase letters, digits or '-', and make a bucket name of at most 63 characters`);
|
||||
}
|
||||
if (this.internalSessions.has(owner)) return { bucket };
|
||||
|
||||
const service = this.serviceFactory({
|
||||
endpoint: this.internal.endpoint,
|
||||
bucket,
|
||||
accessKey: this.internal.accessKey,
|
||||
secretKey: this.internal.secretKey,
|
||||
region: this.internal.region,
|
||||
maxObjectBytes: this.maxObjectBytes,
|
||||
client: this.internalClient
|
||||
});
|
||||
const ensured = await service.ensureBucket();
|
||||
this.internalSessions.set(owner, service);
|
||||
return ensured;
|
||||
}
|
||||
|
||||
async callInternal(method, args, context) {
|
||||
assertArguments(args);
|
||||
const service = this.internalSessions.get(mcpSessionId(context));
|
||||
if (!service) throw new Error("This MCP session has no execution storage");
|
||||
if (!INTERNAL_OPERATIONS.has(method) || typeof service[method] !== "function") throw new Error("Unsupported MinIO operation");
|
||||
return service[method](args);
|
||||
}
|
||||
|
||||
async open(args, context) {
|
||||
assertArguments(args);
|
||||
rejectUnknown(args, ["url", "bucket", "accessKey", "secretKey", "region"]);
|
||||
const owner = mcpSessionId(context);
|
||||
const owned = this.connections.get(owner) ?? new Map();
|
||||
if (owned.size >= this.maxConnectionsPerSession) {
|
||||
throw new Error(`This MCP session already has the maximum of ${this.maxConnectionsPerSession} MinIO sessions`);
|
||||
}
|
||||
|
||||
const endpoint = normalizeEndpoint(args.url);
|
||||
if (this.allowedEndpoints.size > 0 && !this.allowedEndpoints.has(endpoint)) {
|
||||
throw new Error("This MinIO endpoint is not on the server allowlist");
|
||||
}
|
||||
const service = this.serviceFactory({
|
||||
endpoint,
|
||||
bucket: args.bucket,
|
||||
accessKey: args.accessKey,
|
||||
secretKey: args.secretKey,
|
||||
region: args.region ?? this.defaultRegion,
|
||||
maxObjectBytes: this.maxObjectBytes
|
||||
});
|
||||
const id = randomUUID();
|
||||
owned.set(id, service);
|
||||
this.connections.set(owner, owned);
|
||||
try {
|
||||
const verified = await service.verify();
|
||||
return { minioSessionId: id, endpoint: verified.endpoint, bucket: verified.bucket };
|
||||
} catch (error) {
|
||||
owned.delete(id);
|
||||
if (owned.size === 0) this.connections.delete(owner);
|
||||
service.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async call(method, args, context) {
|
||||
assertArguments(args);
|
||||
const { minioSessionId: id, ...operationArgs } = args;
|
||||
if (typeof id !== "string" || id.length === 0) throw new Error("minioSessionId is required");
|
||||
const service = this.connections.get(mcpSessionId(context))?.get(id);
|
||||
if (!service) throw new Error("Unknown MinIO session for this MCP session");
|
||||
if (typeof service[method] !== "function") throw new Error("Unsupported MinIO operation");
|
||||
return service[method](operationArgs);
|
||||
}
|
||||
|
||||
close(args, context) {
|
||||
assertArguments(args);
|
||||
rejectUnknown(args, ["minioSessionId"]);
|
||||
const id = args.minioSessionId;
|
||||
if (typeof id !== "string" || id.length === 0) throw new Error("minioSessionId is required");
|
||||
const owner = mcpSessionId(context);
|
||||
const owned = this.connections.get(owner);
|
||||
const service = owned?.get(id);
|
||||
if (!service) throw new Error("Unknown MinIO session for this MCP session");
|
||||
owned.delete(id);
|
||||
if (owned.size === 0) this.connections.delete(owner);
|
||||
service.destroy();
|
||||
return { minioSessionId: id, closed: true };
|
||||
}
|
||||
|
||||
closeMcpSession(id) {
|
||||
const internal = this.internalSessions.get(id);
|
||||
if (internal) {
|
||||
this.internalSessions.delete(id);
|
||||
internal.destroy();
|
||||
}
|
||||
const owned = this.connections.get(id);
|
||||
if (!owned) return;
|
||||
this.connections.delete(id);
|
||||
for (const service of owned.values()) service.destroy();
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
for (const id of [...this.connections.keys(), ...this.internalSessions.keys()]) this.closeMcpSession(id);
|
||||
this.internalClient?.destroy?.();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env node
|
||||
import { startStreamableHttpServer } from "./streamable-http.js";
|
||||
import { DEFAULT_INTERNAL_BUCKET_PREFIX, INTERNAL_SCOPE_HEADER, MinioConnectionRegistry, internalScopeOf } from "./connection-registry.js";
|
||||
import { INTERNAL_TOOL_DEFINITIONS, TOOL_DEFINITIONS } from "./tool-definitions.js";
|
||||
|
||||
function csv(value) { return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean); }
|
||||
function integer(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
if (value === undefined || value === "") return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) throw new Error(`Invalid positive integer: ${value}`);
|
||||
return parsed;
|
||||
}
|
||||
/**
|
||||
* The platform's own MinIO, for sessions opened with an {@code x-minio-scope} header. Endpoint and
|
||||
* keys together or not at all: a partial configuration would fail on first use, so it fails at
|
||||
* start instead. The bucket prefix defaults rather than being optional - it cannot be emptied.
|
||||
*/
|
||||
function internalStorage() {
|
||||
const values = {
|
||||
endpoint: process.env.MINIO_MCP_INTERNAL_ENDPOINT || "",
|
||||
accessKey: process.env.MINIO_MCP_INTERNAL_ACCESS_KEY || "",
|
||||
secretKey: process.env.MINIO_MCP_INTERNAL_SECRET_KEY || "",
|
||||
region: process.env.MINIO_MCP_INTERNAL_REGION || "",
|
||||
bucketPrefix: process.env.MINIO_MCP_INTERNAL_BUCKET_PREFIX ?? DEFAULT_INTERNAL_BUCKET_PREFIX
|
||||
};
|
||||
const required = ["endpoint", "accessKey", "secretKey"];
|
||||
const set = required.filter((name) => values[name]);
|
||||
if (set.length === 0) return null;
|
||||
if (set.length < required.length) {
|
||||
throw new Error("MINIO_MCP_INTERNAL_ENDPOINT, MINIO_MCP_INTERNAL_ACCESS_KEY and MINIO_MCP_INTERNAL_SECRET_KEY must be set together");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const maxObjectBytes = integer(process.env.MINIO_MCP_MAX_OBJECT_BYTES, 1024 * 1024, 32 * 1024 * 1024);
|
||||
const registry = new MinioConnectionRegistry({
|
||||
maxConnectionsPerSession: integer(process.env.MINIO_MCP_MAX_CONNECTIONS_PER_SESSION, 8, 64),
|
||||
maxObjectBytes,
|
||||
defaultRegion: process.env.MINIO_MCP_DEFAULT_REGION || "us-east-1",
|
||||
allowedEndpoints: csv(process.env.MINIO_MCP_ALLOWED_ENDPOINTS),
|
||||
internal: internalStorage()
|
||||
});
|
||||
|
||||
// Same tool names in both modes; what differs is the connection the operation runs against.
|
||||
const OPERATIONS = {
|
||||
minio_list_objects: "listObjects",
|
||||
minio_get_object: "getObject",
|
||||
minio_stat_object: "statObject",
|
||||
minio_put_object: "putObject"
|
||||
};
|
||||
|
||||
function call(name, args, context) {
|
||||
if (internalScopeOf(context) !== null) return registry.callInternal(OPERATIONS[name], args, context);
|
||||
if (name === "minio_open_session") return registry.open(args, context);
|
||||
if (name === "minio_close_session") return registry.close(args, context);
|
||||
return registry.call(OPERATIONS[name], args, context);
|
||||
}
|
||||
|
||||
async function auditedCall(name, args, context) {
|
||||
const event = {
|
||||
timestamp: new Date().toISOString(),
|
||||
server: "minio-mcp",
|
||||
tool: name,
|
||||
subject: context.subject,
|
||||
sessionId: context.sessionId,
|
||||
mode: internalScopeOf(context) !== null ? "internal" : "external"
|
||||
};
|
||||
try {
|
||||
const result = await call(name, args, context);
|
||||
process.stderr.write(`${JSON.stringify({ ...event, outcome: "success" })}\n`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify({ ...event, outcome: "failure", errorType: error instanceof Error ? error.name : "Error" })}\n`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const listener = await startStreamableHttpServer({
|
||||
serverName: "minio-mcp",
|
||||
serverVersion: "1.0.0",
|
||||
tools: (context) => internalScopeOf(context) !== null ? INTERNAL_TOOL_DEFINITIONS : TOOL_DEFINITIONS,
|
||||
callTool: auditedCall,
|
||||
pinnedHeaders: [INTERNAL_SCOPE_HEADER],
|
||||
// Only a session that asks for the internal storage has a bucket to prepare.
|
||||
onSessionInitialize: async (context) => {
|
||||
if (internalScopeOf(context) !== null) await registry.openInternal(context);
|
||||
},
|
||||
apiKeys: csv(process.env.MINIO_MCP_API_KEYS),
|
||||
allowedOrigins: csv(process.env.MINIO_MCP_CORS_ORIGINS),
|
||||
host: process.env.MINIO_MCP_HOST ?? "0.0.0.0",
|
||||
port: integer(process.env.MINIO_MCP_PORT, 3000, 65535),
|
||||
maxSessions: integer(process.env.MINIO_MCP_MAX_SESSIONS, 16, 1024),
|
||||
sessionTtlMs: integer(process.env.MINIO_MCP_SESSION_TTL_SECONDS, 1800, 86400) * 1000,
|
||||
bodyLimitBytes: integer(process.env.MINIO_MCP_REQUEST_MAX_BYTES, Math.ceil(maxObjectBytes * 4 / 3) + 262144, 48 * 1024 * 1024),
|
||||
onSessionClose: ({ id }) => registry.closeMcpSession(id)
|
||||
});
|
||||
process.stderr.write(`minio-mcp listening on ${listener.host}:${listener.port}\n`);
|
||||
|
||||
async function shutdown() {
|
||||
await listener.close();
|
||||
registry.closeAll();
|
||||
process.exit(0);
|
||||
}
|
||||
process.once("SIGTERM", shutdown);
|
||||
process.once("SIGINT", shutdown);
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
const DEFAULT_PROTOCOL_VERSION = "2025-03-26";
|
||||
|
||||
function toolResult(value) {
|
||||
if (value && Array.isArray(value.content)) return value;
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
||||
structuredContent: value,
|
||||
isError: false
|
||||
};
|
||||
}
|
||||
|
||||
function toolError(error) {
|
||||
return {
|
||||
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code tools} is either a fixed list or a function of the session context, for a server whose
|
||||
* sessions do not all offer the same tools. A tool missing from a session's list cannot be called
|
||||
* in it either.
|
||||
*/
|
||||
export function createMcpHandler({ serverName, serverVersion, tools, callTool }) {
|
||||
return async function handle(message, context) {
|
||||
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
||||
return message?.id === undefined ? null : { jsonrpc: "2.0", id: message.id ?? null, error: { code: -32600, message: "Invalid Request" } };
|
||||
}
|
||||
|
||||
if (message.method === "notifications/initialized" || message.method === "$/cancelRequest") return null;
|
||||
|
||||
let result;
|
||||
try {
|
||||
const sessionTools = typeof tools === "function" ? tools(context) : tools;
|
||||
switch (message.method) {
|
||||
case "initialize":
|
||||
result = {
|
||||
protocolVersion: context.protocolVersion || DEFAULT_PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: serverName, version: serverVersion }
|
||||
};
|
||||
break;
|
||||
case "ping":
|
||||
result = {};
|
||||
break;
|
||||
case "tools/list":
|
||||
result = { tools: sessionTools };
|
||||
break;
|
||||
case "tools/call": {
|
||||
const name = message.params?.name;
|
||||
if (typeof name !== "string" || !sessionTools.some((tool) => tool.name === name)) throw new Error("Unknown tool");
|
||||
try {
|
||||
result = toolResult(await callTool(name, message.params?.arguments ?? {}, context));
|
||||
} catch (error) {
|
||||
result = toolError(error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return message.id === undefined ? null : { jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } };
|
||||
}
|
||||
} catch (error) {
|
||||
return message.id === undefined ? null : {
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32603, message: error instanceof Error ? error.message : "Internal error" }
|
||||
};
|
||||
}
|
||||
|
||||
return message.id === undefined ? null : { jsonrpc: "2.0", id: message.id, result };
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client
|
||||
} from "@aws-sdk/client-s3";
|
||||
|
||||
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function assertPlainObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Arguments must be an object");
|
||||
}
|
||||
|
||||
function rejectUnknown(args, allowed) {
|
||||
const unknown = Object.keys(args).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length > 0) throw new Error(`Unknown argument${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
function objectKey(value) {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error("key must be a non-empty string");
|
||||
if (Buffer.byteLength(value, "utf8") > 1024) throw new Error("key must be at most 1024 UTF-8 bytes");
|
||||
if (CONTROL_CHARACTERS.test(value)) throw new Error("key must not contain control characters");
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoundedString(value, name, maxLength) {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maxLength || CONTROL_CHARACTERS.test(value)) {
|
||||
throw new Error(`${name} must be a non-empty string of at most ${maxLength} characters without control characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function metadata(value) {
|
||||
if (value === undefined) return undefined;
|
||||
assertPlainObject(value);
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > 32) throw new Error("metadata may contain at most 32 entries");
|
||||
const result = {};
|
||||
for (const [key, item] of entries) {
|
||||
if (!/^[A-Za-z0-9!#$&'*+.^_`|~-]{1,128}$/.test(key)) throw new Error(`Invalid metadata key: ${key}`);
|
||||
if (typeof item !== "string" || item.length > 2048 || CONTROL_CHARACTERS.test(item)) {
|
||||
throw new Error(`Metadata value for ${key} must be a string of at most 2048 characters without control characters`);
|
||||
}
|
||||
result[key] = item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function positiveInteger(value, name, fallback, maximum) {
|
||||
const candidate = value === undefined ? fallback : value;
|
||||
if (!Number.isInteger(candidate) || candidate < 1 || candidate > maximum) {
|
||||
throw new Error(`${name} must be an integer between 1 and ${maximum}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function isoDate(value) {
|
||||
return value instanceof Date ? value.toISOString() : undefined;
|
||||
}
|
||||
|
||||
function cleanEtag(value) {
|
||||
return typeof value === "string" ? value.replace(/^"|"$/g, "") : undefined;
|
||||
}
|
||||
|
||||
function safeServiceError(action, error) {
|
||||
const name = error && typeof error === "object" && typeof error.name === "string" ? error.name : "Error";
|
||||
const status = error && typeof error === "object" ? error.$metadata?.httpStatusCode : undefined;
|
||||
const requestId = error && typeof error === "object" ? error.$metadata?.requestId : undefined;
|
||||
const details = [name, status ? `HTTP ${status}` : null, requestId ? `request ${requestId}` : null].filter(Boolean).join(", ");
|
||||
return new Error(`MinIO ${action} failed${details ? ` (${details})` : ""}`, { cause: error });
|
||||
}
|
||||
|
||||
async function readBoundedBody(body, maximumBytes) {
|
||||
if (!body || typeof body[Symbol.asyncIterator] !== "function") throw new Error("MinIO returned an unreadable object body");
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for await (const chunk of body) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += bytes.length;
|
||||
if (total > maximumBytes) throw new Error(`Object exceeds the configured ${maximumBytes}-byte read limit`);
|
||||
chunks.push(bytes);
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof body.destroy === "function") body.destroy();
|
||||
throw error;
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
export function normalizeEndpoint(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error("url is required");
|
||||
let endpoint;
|
||||
try { endpoint = new URL(value); }
|
||||
catch { throw new Error("url must be a valid HTTP(S) URL"); }
|
||||
if (!(["http:", "https:"].includes(endpoint.protocol))) throw new Error("url must use http or https");
|
||||
if (endpoint.username || endpoint.password) throw new Error("url must not contain credentials");
|
||||
if (endpoint.search || endpoint.hash || !(["", "/"].includes(endpoint.pathname))) {
|
||||
throw new Error("url must be an endpoint origin without path, query or fragment");
|
||||
}
|
||||
return endpoint.origin;
|
||||
}
|
||||
|
||||
export function createS3Client({ endpoint, region, accessKey, secretKey }) {
|
||||
return new S3Client({
|
||||
endpoint: normalizeEndpoint(endpoint),
|
||||
region,
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }
|
||||
});
|
||||
}
|
||||
|
||||
function isMissingBucket(error) {
|
||||
const name = error && typeof error === "object" ? error.name : undefined;
|
||||
return name === "NotFound" || name === "NoSuchBucket" || error?.$metadata?.httpStatusCode === 404;
|
||||
}
|
||||
|
||||
export class MinioService {
|
||||
/**
|
||||
* A {@code client} passed in is shared - the internal one, used by every execution's session - so
|
||||
* {@link #destroy} leaves it open; a client this service built for itself it closes.
|
||||
*/
|
||||
constructor({ endpoint, bucket, accessKey, secretKey, region = "us-east-1", maxObjectBytes = 1024 * 1024, client }) {
|
||||
this.endpoint = normalizeEndpoint(endpoint);
|
||||
if (typeof bucket !== "string" || bucket.trim() === "" || bucket.length > 255 || bucket.includes("/") || CONTROL_CHARACTERS.test(bucket)) {
|
||||
throw new Error("bucket must be a valid non-empty bucket name");
|
||||
}
|
||||
if (typeof accessKey !== "string" || accessKey.length === 0 || accessKey.length > 1024) throw new Error("accessKey must contain between 1 and 1024 characters");
|
||||
if (typeof secretKey !== "string" || secretKey.length === 0 || secretKey.length > 4096) throw new Error("secretKey must contain between 1 and 4096 characters");
|
||||
if (typeof region !== "string" || region.length === 0 || region.length > 128 || CONTROL_CHARACTERS.test(region)) {
|
||||
throw new Error("region must contain between 1 and 128 characters without control characters");
|
||||
}
|
||||
if (!Number.isInteger(maxObjectBytes) || maxObjectBytes < 1) throw new Error("maxObjectBytes must be a positive integer");
|
||||
|
||||
this.bucket = bucket;
|
||||
this.maxObjectBytes = maxObjectBytes;
|
||||
this.ownsClient = !client;
|
||||
this.client = client ?? createS3Client({ endpoint: this.endpoint, region, accessKey, secretKey });
|
||||
}
|
||||
|
||||
async verify() {
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { endpoint: this.endpoint, bucket: this.bucket };
|
||||
} catch (error) {
|
||||
throw safeServiceError("connection", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bucket, created when missing - the internal storage's counterpart of the coding agent's
|
||||
* ensureWorkspace. Two sessions of one execution may race to create it, so the creation the other
|
||||
* one won counts as success.
|
||||
*/
|
||||
async ensureBucket() {
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { bucket: this.bucket, created: false };
|
||||
} catch (error) {
|
||||
if (!isMissingBucket(error)) throw safeServiceError("connection", error);
|
||||
}
|
||||
try {
|
||||
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
||||
return { bucket: this.bucket, created: true };
|
||||
} catch (error) {
|
||||
if (error?.name === "BucketAlreadyOwnedByYou" || error?.name === "BucketAlreadyExists") {
|
||||
return { bucket: this.bucket, created: false };
|
||||
}
|
||||
throw safeServiceError("bucket creation", error);
|
||||
}
|
||||
}
|
||||
|
||||
async listObjects(args) {
|
||||
assertPlainObject(args);
|
||||
rejectUnknown(args, ["prefix", "delimiter", "maxKeys", "continuationToken"]);
|
||||
const prefix = args.prefix === undefined ? "" : args.prefix;
|
||||
if (typeof prefix !== "string" || Buffer.byteLength(prefix, "utf8") > 1024 || CONTROL_CHARACTERS.test(prefix)) {
|
||||
throw new Error("prefix must be a string of at most 1024 UTF-8 bytes without control characters");
|
||||
}
|
||||
const delimiter = optionalBoundedString(args.delimiter, "delimiter", 16);
|
||||
const continuationToken = optionalBoundedString(args.continuationToken, "continuationToken", 8192);
|
||||
const maxKeys = positiveInteger(args.maxKeys, "maxKeys", 100, 1000);
|
||||
|
||||
try {
|
||||
const result = await this.client.send(new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
Delimiter: delimiter,
|
||||
MaxKeys: maxKeys,
|
||||
ContinuationToken: continuationToken
|
||||
}));
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
objects: (result.Contents ?? []).map((item) => ({
|
||||
key: item.Key,
|
||||
size: item.Size,
|
||||
etag: cleanEtag(item.ETag),
|
||||
lastModified: isoDate(item.LastModified),
|
||||
storageClass: item.StorageClass
|
||||
})),
|
||||
commonPrefixes: (result.CommonPrefixes ?? []).map((item) => item.Prefix).filter((item) => typeof item === "string"),
|
||||
isTruncated: result.IsTruncated === true,
|
||||
nextContinuationToken: result.NextContinuationToken
|
||||
};
|
||||
} catch (error) {
|
||||
throw safeServiceError("list", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getObject(args) {
|
||||
assertPlainObject(args);
|
||||
rejectUnknown(args, ["key", "encoding"]);
|
||||
const key = objectKey(args.key);
|
||||
const encoding = args.encoding ?? "utf8";
|
||||
if (!(encoding === "utf8" || encoding === "base64")) throw new Error("encoding must be utf8 or base64");
|
||||
|
||||
try {
|
||||
const result = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
if (typeof result.ContentLength === "number" && result.ContentLength > this.maxObjectBytes) {
|
||||
if (typeof result.Body?.destroy === "function") result.Body.destroy();
|
||||
throw new Error(`Object exceeds the configured ${this.maxObjectBytes}-byte read limit`);
|
||||
}
|
||||
const bytes = await readBoundedBody(result.Body, this.maxObjectBytes);
|
||||
let content;
|
||||
if (encoding === "base64") {
|
||||
content = bytes.toString("base64");
|
||||
} else {
|
||||
try { content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
||||
catch { throw new Error("Object is not valid UTF-8; read it again with encoding=base64"); }
|
||||
}
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
key,
|
||||
encoding,
|
||||
content,
|
||||
size: bytes.length,
|
||||
contentType: result.ContentType,
|
||||
etag: cleanEtag(result.ETag),
|
||||
lastModified: isoDate(result.LastModified),
|
||||
metadata: result.Metadata ?? {}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.message.startsWith("Object exceeds") || error.message.startsWith("Object is not valid") || error.message.startsWith("MinIO returned"))) throw error;
|
||||
throw safeServiceError("read", error);
|
||||
}
|
||||
}
|
||||
|
||||
async statObject(args) {
|
||||
assertPlainObject(args);
|
||||
rejectUnknown(args, ["key"]);
|
||||
const key = objectKey(args.key);
|
||||
try {
|
||||
const result = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
key,
|
||||
size: result.ContentLength,
|
||||
contentType: result.ContentType,
|
||||
etag: cleanEtag(result.ETag),
|
||||
lastModified: isoDate(result.LastModified),
|
||||
metadata: result.Metadata ?? {}
|
||||
};
|
||||
} catch (error) {
|
||||
throw safeServiceError("stat", error);
|
||||
}
|
||||
}
|
||||
|
||||
async putObject(args) {
|
||||
assertPlainObject(args);
|
||||
rejectUnknown(args, ["key", "content", "contentBase64", "contentType", "metadata"]);
|
||||
const key = objectKey(args.key);
|
||||
const hasText = typeof args.content === "string";
|
||||
const hasBase64 = typeof args.contentBase64 === "string";
|
||||
if (hasText === hasBase64) throw new Error("Provide exactly one of content or contentBase64");
|
||||
|
||||
let bytes;
|
||||
if (hasText) {
|
||||
bytes = Buffer.from(args.content, "utf8");
|
||||
} else {
|
||||
if (!BASE64_PATTERN.test(args.contentBase64)) throw new Error("contentBase64 must be valid canonical base64");
|
||||
bytes = Buffer.from(args.contentBase64, "base64");
|
||||
}
|
||||
if (bytes.length > this.maxObjectBytes) throw new Error(`Object exceeds the configured ${this.maxObjectBytes}-byte write limit`);
|
||||
|
||||
const contentType = optionalBoundedString(args.contentType, "contentType", 255)
|
||||
?? (hasText ? "text/plain; charset=utf-8" : "application/octet-stream");
|
||||
const objectMetadata = metadata(args.metadata);
|
||||
|
||||
try {
|
||||
const result = await this.client.send(new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: bytes,
|
||||
ContentLength: bytes.length,
|
||||
ContentType: contentType,
|
||||
Metadata: objectMetadata
|
||||
}));
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
key,
|
||||
size: bytes.length,
|
||||
contentType,
|
||||
etag: cleanEtag(result.ETag),
|
||||
versionId: result.VersionId
|
||||
};
|
||||
} catch (error) {
|
||||
throw safeServiceError("write", error);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.ownsClient) this.client.destroy?.();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import http from "node:http";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { URL } from "node:url";
|
||||
import { createMcpHandler } from "./mcp-core.js";
|
||||
|
||||
const DEFAULT_PROTOCOL_VERSION = "2025-03-26";
|
||||
|
||||
function sendJson(response, status, value, headers = {}) {
|
||||
if (response.destroyed || response.writableEnded) return;
|
||||
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function collectJson(request, limitBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > limitBytes) {
|
||||
reject(new Error("Request body too large"));
|
||||
request.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on("end", () => {
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (Array.isArray(value)) throw new Error("JSON-RPC batches are disabled");
|
||||
resolve(value);
|
||||
} catch (error) { reject(error); }
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseCredentials(entries) {
|
||||
const result = new Map();
|
||||
for (const raw of entries) {
|
||||
const separator = raw.indexOf("=");
|
||||
const subject = separator < 0 ? "shared" : raw.slice(0, separator).trim();
|
||||
const token = (separator < 0 ? raw : raw.slice(separator + 1)).trim();
|
||||
if (!subject || token.length < 32) throw new Error("Each API token must contain at least 32 characters");
|
||||
result.set(subject, Buffer.from(token));
|
||||
}
|
||||
if (result.size === 0) throw new Error("At least one API token is required");
|
||||
return result;
|
||||
}
|
||||
|
||||
function authenticate(request, credentials) {
|
||||
const bearer = typeof request.headers.authorization === "string"
|
||||
? /^Bearer\s+(.+)$/i.exec(request.headers.authorization)?.[1]?.trim()
|
||||
: null;
|
||||
const token = bearer || (typeof request.headers["x-api-key"] === "string" ? request.headers["x-api-key"].trim() : null);
|
||||
if (!token) return null;
|
||||
const candidate = Buffer.from(token);
|
||||
for (const [subject, expected] of credentials) {
|
||||
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) return subject;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionHeaders(session) {
|
||||
return { "mcp-session-id": session.id, "mcp-protocol-version": session.protocolVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the headers a server scopes its work by, once, at session open.
|
||||
*
|
||||
* <p>The values name a scope the caller may not choose for itself - an execution, say - so they
|
||||
* arrive as a header set by whoever opened the session rather than as a tool argument the model
|
||||
* could write. Read here and frozen onto the session, they cannot be varied per request.
|
||||
*/
|
||||
function readPinnedHeaders(request, names) {
|
||||
const pinned = {};
|
||||
for (const name of names) {
|
||||
const value = request.headers[name];
|
||||
if (value === undefined) continue;
|
||||
if (typeof value !== "string" || value.length > 256 || !/^[\x20-\x7e]*$/.test(value)) throw new Error(`Invalid ${name} header`);
|
||||
pinned[name] = value.trim();
|
||||
}
|
||||
return pinned;
|
||||
}
|
||||
|
||||
export function startStreamableHttpServer({
|
||||
serverName,
|
||||
serverVersion,
|
||||
tools,
|
||||
callTool,
|
||||
apiKeys,
|
||||
allowedOrigins = [],
|
||||
host = "127.0.0.1",
|
||||
port = 3000,
|
||||
maxSessions = 32,
|
||||
sessionTtlMs = 30 * 60 * 1000,
|
||||
bodyLimitBytes = 2 * 1024 * 1024,
|
||||
onSessionClose = async () => {},
|
||||
// Runs once per session, before the initialize response: what the pinned headers ask for is
|
||||
// prepared here, so a session that cannot have it never opens.
|
||||
onSessionInitialize = async () => {},
|
||||
pinnedHeaders = []
|
||||
}) {
|
||||
const credentials = parseCredentials(apiKeys);
|
||||
const origins = new Set(allowedOrigins);
|
||||
const sessions = new Map();
|
||||
const handleMcp = createMcpHandler({ serverName, serverVersion, tools, callTool });
|
||||
|
||||
async function closeSession(session) {
|
||||
if (!sessions.delete(session.id)) return;
|
||||
for (const response of session.listeners) response.end();
|
||||
session.listeners.clear();
|
||||
try { await onSessionClose(session); }
|
||||
catch {}
|
||||
}
|
||||
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const session of sessions.values()) {
|
||||
if (now - session.lastSeen > sessionTtlMs) void closeSession(session);
|
||||
}
|
||||
}, Math.min(sessionTtlMs, 60_000));
|
||||
cleanup.unref();
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
response.on("error", () => {});
|
||||
response.setHeader("x-content-type-options", "nosniff");
|
||||
response.setHeader("referrer-policy", "no-referrer");
|
||||
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
||||
const origin = request.headers.origin;
|
||||
if (typeof origin === "string") {
|
||||
if (!origins.has(origin)) return sendJson(response, 403, { error: "Origin is not allowed" });
|
||||
response.setHeader("access-control-allow-origin", origin);
|
||||
response.setHeader("vary", "Origin");
|
||||
response.setHeader("access-control-allow-headers", ["authorization, x-api-key, content-type, mcp-session-id, mcp-protocol-version", ...pinnedHeaders].join(", "));
|
||||
response.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
|
||||
}
|
||||
if (request.method === "OPTIONS") {
|
||||
if (typeof origin !== "string") return sendJson(response, 403, { error: "Origin is required" });
|
||||
response.writeHead(204); response.end(); return;
|
||||
}
|
||||
|
||||
const subject = authenticate(request, credentials);
|
||||
if (!subject) {
|
||||
response.setHeader("www-authenticate", "Bearer");
|
||||
return sendJson(response, 401, { error: "Unauthorized" });
|
||||
}
|
||||
if (request.method === "GET" && requestUrl.pathname === "/health") {
|
||||
return sendJson(response, 200, { ok: true, serverName, transport: "streamable-http" });
|
||||
}
|
||||
if (requestUrl.pathname !== "/mcp") return sendJson(response, 404, { error: "Not found" });
|
||||
|
||||
const requestedId = request.headers["mcp-session-id"];
|
||||
let session = typeof requestedId === "string" ? sessions.get(requestedId) : null;
|
||||
if (requestedId && (!session || session.subject !== subject)) return sendJson(response, 404, { error: "Unknown MCP session" });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
if (!session) return sendJson(response, 404, { error: "Unknown MCP session" });
|
||||
await closeSession(session);
|
||||
response.writeHead(204); response.end(); return;
|
||||
}
|
||||
|
||||
if (request.method === "GET") {
|
||||
if (!session) return sendJson(response, 400, { error: "Mcp-Session-Id is required" });
|
||||
session.lastSeen = Date.now();
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
...sessionHeaders(session)
|
||||
});
|
||||
response.write(": connected\n\n");
|
||||
session.listeners.add(response);
|
||||
request.once("close", () => session.listeners.delete(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== "POST") return sendJson(response, 405, { error: "Method not allowed" });
|
||||
let message;
|
||||
try { message = await collectJson(request, bodyLimitBytes); }
|
||||
catch (error) { return sendJson(response, 400, { error: error instanceof Error ? error.message : "Invalid JSON" }); }
|
||||
|
||||
if (!session) {
|
||||
if (message?.method !== "initialize") return sendJson(response, 400, { error: "Initialize the MCP session first" });
|
||||
if (sessions.size >= maxSessions) return sendJson(response, 503, { error: "Session capacity reached" });
|
||||
let pinned;
|
||||
try { pinned = readPinnedHeaders(request, pinnedHeaders); }
|
||||
catch (error) { return sendJson(response, 400, { error: error instanceof Error ? error.message : "Invalid header" }); }
|
||||
session = {
|
||||
id: randomUUID(),
|
||||
subject,
|
||||
protocolVersion: typeof message.params?.protocolVersion === "string" ? message.params.protocolVersion : DEFAULT_PROTOCOL_VERSION,
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
listeners: new Set(),
|
||||
pinned
|
||||
};
|
||||
sessions.set(session.id, session);
|
||||
try {
|
||||
await onSessionInitialize({ sessionId: session.id, subject, pinned });
|
||||
} catch (error) {
|
||||
await closeSession(session);
|
||||
const status = Number.isInteger(error?.statusCode) ? error.statusCode : 500;
|
||||
return sendJson(response, status, { error: `Unable to initialize the session: ${error instanceof Error ? error.message : String(error)}` });
|
||||
}
|
||||
} else {
|
||||
session.lastSeen = Date.now();
|
||||
// A pinned header belongs to the session, not to the request: letting it change mid-session
|
||||
// would let one caller reach another scope without opening a session for it.
|
||||
for (const name of pinnedHeaders) {
|
||||
const supplied = request.headers[name];
|
||||
if (typeof supplied === "string" && supplied.trim() !== (session.pinned[name] ?? "")) {
|
||||
return sendJson(response, 400, { error: `${name} cannot change within a session` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rpcResponse = await handleMcp(message, {
|
||||
sessionId: session.id,
|
||||
subject,
|
||||
protocolVersion: session.protocolVersion,
|
||||
pinned: session.pinned ?? {}
|
||||
});
|
||||
if (rpcResponse === null) {
|
||||
response.writeHead(202, sessionHeaders(session)); response.end(); return;
|
||||
}
|
||||
return sendJson(response, 200, rpcResponse, sessionHeaders(session));
|
||||
});
|
||||
|
||||
server.headersTimeout = 10_000;
|
||||
server.requestTimeout = 120_000;
|
||||
server.maxRequestsPerSocket = 1000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve({
|
||||
server,
|
||||
host,
|
||||
port: server.address().port,
|
||||
close: async () => {
|
||||
clearInterval(cleanup);
|
||||
await Promise.all([...sessions.values()].map(closeSession));
|
||||
await new Promise((done, fail) => server.close((error) => error ? fail(error) : done()));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
const minioSessionId = {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
description: "Opaque identifier returned by minio_open_session. It is valid only inside the current MCP session."
|
||||
};
|
||||
|
||||
const objectKey = {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 1024,
|
||||
description: "Object key inside the session's bucket. Nested keys such as reports/2026/result.json are supported."
|
||||
};
|
||||
|
||||
export const TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: "minio_open_session",
|
||||
description: "Open and verify a session to a MinIO S3 endpoint. Credentials are retained in memory only for the current MCP session and are never returned.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", minLength: 1, maxLength: 4096, description: "MinIO S3 API origin, such as https://s3.example.org. Do not use the console URL." },
|
||||
bucket: { type: "string", minLength: 1, maxLength: 255 },
|
||||
accessKey: { type: "string", minLength: 1, maxLength: 1024, writeOnly: true },
|
||||
secretKey: { type: "string", minLength: 1, maxLength: 4096, writeOnly: true },
|
||||
region: { type: "string", minLength: 1, maxLength: 128, default: "us-east-1" }
|
||||
},
|
||||
required: ["url", "bucket", "accessKey", "secretKey"],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "minio_close_session",
|
||||
description: "Close a MinIO session and immediately discard its credentials.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { minioSessionId },
|
||||
required: ["minioSessionId"],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "minio_list_objects",
|
||||
description: "List a bounded page of objects using an open MinIO session.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minioSessionId,
|
||||
prefix: { type: "string", maxLength: 1024, default: "" },
|
||||
delimiter: { type: "string", minLength: 1, maxLength: 16, description: "Usually '/' to group keys like folders." },
|
||||
maxKeys: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
|
||||
continuationToken: { type: "string", minLength: 1, maxLength: 8192 }
|
||||
},
|
||||
required: ["minioSessionId"],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "minio_get_object",
|
||||
description: "Read one bounded object through an open MinIO session as UTF-8 text or base64.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minioSessionId,
|
||||
key: objectKey,
|
||||
encoding: { type: "string", enum: ["utf8", "base64"], default: "utf8" }
|
||||
},
|
||||
required: ["minioSessionId", "key"],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "minio_stat_object",
|
||||
description: "Read metadata for one object through an open MinIO session without downloading its content.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { minioSessionId, key: objectKey },
|
||||
required: ["minioSessionId", "key"],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "minio_put_object",
|
||||
description: "Create or replace one bounded object through an open MinIO session. Provide exactly one of content or contentBase64.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minioSessionId,
|
||||
key: objectKey,
|
||||
content: { type: "string", description: "UTF-8 text to store." },
|
||||
contentBase64: { type: "string", description: "Base64-encoded binary content to store." },
|
||||
contentType: { type: "string", minLength: 1, maxLength: 255 },
|
||||
metadata: {
|
||||
type: "object",
|
||||
maxProperties: 32,
|
||||
additionalProperties: { type: "string", maxLength: 2048 }
|
||||
}
|
||||
},
|
||||
required: ["minioSessionId", "key"],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const INTERNAL_DESCRIPTIONS = {
|
||||
minio_list_objects: "List a bounded page of objects in this execution's storage.",
|
||||
minio_get_object: "Read one bounded object from this execution's storage as UTF-8 text or base64.",
|
||||
minio_stat_object: "Read metadata for one object in this execution's storage without downloading its content.",
|
||||
minio_put_object: "Create or replace one bounded object in this execution's storage. Provide exactly one of content or contentBase64."
|
||||
};
|
||||
|
||||
/**
|
||||
* The tools of a session bound to the internal storage: the same object operations, with no session
|
||||
* to open or name. The storage was chosen when the session opened, so there is nothing for the
|
||||
* model to supply but keys.
|
||||
*/
|
||||
export const INTERNAL_TOOL_DEFINITIONS = TOOL_DEFINITIONS
|
||||
.filter((tool) => tool.name in INTERNAL_DESCRIPTIONS)
|
||||
.map((tool) => {
|
||||
const { minioSessionId: _omitted, ...properties } = tool.inputSchema.properties;
|
||||
return {
|
||||
name: tool.name,
|
||||
description: INTERNAL_DESCRIPTIONS[tool.name],
|
||||
inputSchema: {
|
||||
...tool.inputSchema,
|
||||
properties,
|
||||
required: tool.inputSchema.required.filter((name) => name !== "minioSessionId")
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { MinioConnectionRegistry } from "../src/connection-registry.js";
|
||||
|
||||
function fixture(maxConnectionsPerSession = 2) {
|
||||
const services = [];
|
||||
const registry = new MinioConnectionRegistry({
|
||||
maxConnectionsPerSession,
|
||||
maxObjectBytes: 2048,
|
||||
defaultRegion: "eu-test-1",
|
||||
serviceFactory: (options) => {
|
||||
const service = {
|
||||
options,
|
||||
destroyed: false,
|
||||
async verify() { return { endpoint: options.endpoint, bucket: options.bucket }; },
|
||||
async getObject(args) { return { bucket: options.bucket, ...args }; },
|
||||
destroy() { this.destroyed = true; }
|
||||
};
|
||||
services.push(service);
|
||||
return service;
|
||||
}
|
||||
});
|
||||
return { registry, services };
|
||||
}
|
||||
|
||||
const connection = {
|
||||
url: "https://minio-one.example.test",
|
||||
bucket: "one",
|
||||
accessKey: "access",
|
||||
secretKey: "secret"
|
||||
};
|
||||
|
||||
test("opens multiple MinIO sessions and routes calls by opaque id", async () => {
|
||||
const { registry, services } = fixture();
|
||||
const context = { sessionId: "mcp-a" };
|
||||
const first = await registry.open(connection, context);
|
||||
const second = await registry.open({ ...connection, url: "https://minio-two.example.test", bucket: "two" }, context);
|
||||
|
||||
assert.notEqual(first.minioSessionId, second.minioSessionId);
|
||||
assert.equal(services[0].options.region, "eu-test-1");
|
||||
assert.equal(services[0].options.maxObjectBytes, 2048);
|
||||
assert.deepEqual(
|
||||
await registry.call("getObject", { minioSessionId: second.minioSessionId, key: "result.txt" }, context),
|
||||
{ bucket: "two", key: "result.txt" }
|
||||
);
|
||||
});
|
||||
|
||||
test("does not allow a MinIO session id to cross MCP-session boundaries", async () => {
|
||||
const { registry } = fixture();
|
||||
const opened = await registry.open(connection, { sessionId: "mcp-a" });
|
||||
await assert.rejects(
|
||||
registry.call("getObject", { minioSessionId: opened.minioSessionId, key: "result.txt" }, { sessionId: "mcp-b" }),
|
||||
/Unknown MinIO session/
|
||||
);
|
||||
});
|
||||
|
||||
test("closes individual connections and all connections owned by an MCP session", async () => {
|
||||
const { registry, services } = fixture();
|
||||
const context = { sessionId: "mcp-a" };
|
||||
const first = await registry.open(connection, context);
|
||||
await registry.open({ ...connection, bucket: "two" }, context);
|
||||
|
||||
assert.deepEqual(registry.close({ minioSessionId: first.minioSessionId }, context), {
|
||||
minioSessionId: first.minioSessionId,
|
||||
closed: true
|
||||
});
|
||||
assert.equal(services[0].destroyed, true);
|
||||
assert.equal(services[1].destroyed, false);
|
||||
|
||||
registry.closeMcpSession("mcp-a");
|
||||
assert.equal(services[1].destroyed, true);
|
||||
assert.equal(registry.connections.size, 0);
|
||||
});
|
||||
|
||||
test("enforces the per-MCP-session connection limit", async () => {
|
||||
const { registry } = fixture(1);
|
||||
const context = { sessionId: "mcp-a" };
|
||||
await registry.open(connection, context);
|
||||
await assert.rejects(registry.open({ ...connection, bucket: "two" }, context), /maximum of 1/);
|
||||
});
|
||||
|
||||
test("optionally restricts dynamic MinIO endpoints to an allowlist", async () => {
|
||||
const registry = new MinioConnectionRegistry({
|
||||
allowedEndpoints: ["https://allowed.example.test/"],
|
||||
serviceFactory: () => { throw new Error("service should not be created"); }
|
||||
});
|
||||
await assert.rejects(
|
||||
registry.open(connection, { sessionId: "mcp-a" }),
|
||||
/not on the server allowlist/
|
||||
);
|
||||
});
|
||||
|
||||
test("destroys a client whose connection verification fails", async () => {
|
||||
let destroyed = false;
|
||||
const registry = new MinioConnectionRegistry({
|
||||
serviceFactory: () => ({
|
||||
async verify() { throw new Error("cannot connect"); },
|
||||
destroy() { destroyed = true; }
|
||||
})
|
||||
});
|
||||
await assert.rejects(registry.open(connection, { sessionId: "mcp-a" }), /cannot connect/);
|
||||
assert.equal(destroyed, true);
|
||||
assert.equal(registry.connections.size, 0);
|
||||
});
|
||||
|
||||
const internalConfig = {
|
||||
endpoint: "http://minio:9000",
|
||||
accessKey: "platform-access",
|
||||
secretKey: "platform-secret"
|
||||
};
|
||||
|
||||
function internalFixture() {
|
||||
const services = [];
|
||||
const sharedClient = { destroyed: false, destroy() { this.destroyed = true; } };
|
||||
const registry = new MinioConnectionRegistry({
|
||||
maxObjectBytes: 2048,
|
||||
defaultRegion: "eu-test-1",
|
||||
internal: internalConfig,
|
||||
clientFactory: () => sharedClient,
|
||||
serviceFactory: (options) => {
|
||||
const service = {
|
||||
options,
|
||||
ensured: 0,
|
||||
destroyed: false,
|
||||
async ensureBucket() { this.ensured += 1; return { bucket: options.bucket, created: true }; },
|
||||
async getObject(args) { return { bucket: options.bucket, ...args }; },
|
||||
destroy() { this.destroyed = true; }
|
||||
};
|
||||
services.push(service);
|
||||
return service;
|
||||
}
|
||||
});
|
||||
return { registry, services, sharedClient };
|
||||
}
|
||||
|
||||
const executionId = "9f3d2c8c-16b1-4a89-baf8-9d45b2d67e10";
|
||||
const executionBucket = `exec-${executionId}`;
|
||||
function internalContext(sessionId, scope = executionId) {
|
||||
return { sessionId, pinned: { "x-minio-scope": scope } };
|
||||
}
|
||||
|
||||
test("binds an internal session to its execution's bucket, behind the prefix, on the platform MinIO", async () => {
|
||||
const { registry, services, sharedClient } = internalFixture();
|
||||
const context = internalContext("mcp-a");
|
||||
assert.deepEqual(await registry.openInternal(context), { bucket: executionBucket, created: true });
|
||||
|
||||
assert.equal(services[0].ensured, 1);
|
||||
assert.equal(services[0].options.endpoint, "http://minio:9000");
|
||||
assert.equal(services[0].options.accessKey, "platform-access");
|
||||
assert.equal(services[0].options.region, "eu-test-1");
|
||||
assert.equal(services[0].options.client, sharedClient);
|
||||
assert.deepEqual(
|
||||
await registry.callInternal("getObject", { key: "result.txt" }, context),
|
||||
{ bucket: executionBucket, key: "result.txt" }
|
||||
);
|
||||
});
|
||||
|
||||
test("prepares an internal session's bucket once", async () => {
|
||||
const { registry, services } = internalFixture();
|
||||
const context = internalContext("mcp-a");
|
||||
await registry.openInternal(context);
|
||||
await registry.openInternal(context);
|
||||
assert.equal(services.length, 1);
|
||||
});
|
||||
|
||||
test("refuses an internal session whose scope cannot make a valid bucket", async () => {
|
||||
const { registry, services } = internalFixture();
|
||||
for (const scope of ["", "Upper-Case", "../other", "has.dot", "trailing-", "x".repeat(59)]) {
|
||||
await assert.rejects(registry.openInternal(internalContext("mcp-a", scope)), (error) => error.statusCode === 400);
|
||||
}
|
||||
assert.equal(services.length, 0);
|
||||
});
|
||||
|
||||
test("keeps every internal bucket behind the prefix, whatever the scope names", async () => {
|
||||
const { registry, services } = internalFixture();
|
||||
// A caller naming another bucket outright still lands in its own.
|
||||
await registry.openInternal(internalContext("mcp-a", "storage-nodes-bucket"));
|
||||
assert.equal(services[0].options.bucket, "exec-storage-nodes-bucket");
|
||||
});
|
||||
|
||||
test("uses a configured bucket prefix and refuses an empty or invalid one", () => {
|
||||
const registry = new MinioConnectionRegistry({
|
||||
internal: { ...internalConfig, bucketPrefix: "hf-run-" },
|
||||
clientFactory: () => ({})
|
||||
});
|
||||
assert.equal(registry.internal.bucketPrefix, "hf-run-");
|
||||
for (const bucketPrefix of ["", "-x", "Exec-", "has.dot", "x".repeat(28)]) {
|
||||
assert.throws(
|
||||
() => new MinioConnectionRegistry({ internal: { ...internalConfig, bucketPrefix }, clientFactory: () => ({}) }),
|
||||
/internal bucket prefix/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses an internal session when no internal MinIO is configured", async () => {
|
||||
const registry = new MinioConnectionRegistry({ serviceFactory: () => { throw new Error("service should not be created"); } });
|
||||
await assert.rejects(registry.openInternal(internalContext("mcp-a")), /not configured/);
|
||||
});
|
||||
|
||||
test("keeps internal sessions apart and offers them only object operations", async () => {
|
||||
const { registry } = internalFixture();
|
||||
await registry.openInternal(internalContext("mcp-a"));
|
||||
await assert.rejects(registry.callInternal("getObject", { key: "x" }, internalContext("mcp-b")), /no execution storage/);
|
||||
await assert.rejects(registry.callInternal("ensureBucket", {}, internalContext("mcp-a")), /Unsupported MinIO operation/);
|
||||
});
|
||||
|
||||
test("releases an internal session without closing the shared client until shutdown", async () => {
|
||||
const { registry, services, sharedClient } = internalFixture();
|
||||
await registry.openInternal(internalContext("mcp-a"));
|
||||
registry.closeMcpSession("mcp-a");
|
||||
assert.equal(services[0].destroyed, true);
|
||||
assert.equal(sharedClient.destroyed, false);
|
||||
assert.equal(registry.internalSessions.size, 0);
|
||||
|
||||
registry.closeAll();
|
||||
assert.equal(sharedClient.destroyed, true);
|
||||
});
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { MinioService, normalizeEndpoint } from "../src/minio-service.js";
|
||||
|
||||
class FakeClient {
|
||||
constructor(handler) {
|
||||
this.handler = handler;
|
||||
this.commands = [];
|
||||
}
|
||||
|
||||
async send(command) {
|
||||
this.commands.push(command);
|
||||
return this.handler(command);
|
||||
}
|
||||
}
|
||||
|
||||
function service(client, maxObjectBytes = 1024) {
|
||||
return new MinioService({
|
||||
endpoint: "https://minio.example.test",
|
||||
bucket: "agent-data",
|
||||
accessKey: "test-access-key",
|
||||
secretKey: "test-secret-key",
|
||||
maxObjectBytes,
|
||||
client
|
||||
});
|
||||
}
|
||||
|
||||
test("normalizes a MinIO endpoint and rejects unsafe endpoint forms", () => {
|
||||
assert.equal(normalizeEndpoint("https://minio.example.test/"), "https://minio.example.test");
|
||||
assert.equal(normalizeEndpoint("http://minio:9000"), "http://minio:9000");
|
||||
assert.throws(() => normalizeEndpoint("https://user:secret@minio.example.test"), /must not contain credentials/);
|
||||
assert.throws(() => normalizeEndpoint("https://minio.example.test/s3"), /without path/);
|
||||
assert.throws(() => normalizeEndpoint("file:///tmp/minio"), /must use http or https/);
|
||||
});
|
||||
|
||||
test("verifies the configured bucket before opening a logical session", async () => {
|
||||
const client = new FakeClient(async (command) => {
|
||||
assert.ok(command instanceof HeadBucketCommand);
|
||||
return {};
|
||||
});
|
||||
assert.deepEqual(await service(client).verify(), {
|
||||
endpoint: "https://minio.example.test",
|
||||
bucket: "agent-data"
|
||||
});
|
||||
assert.equal(client.commands[0].input.Bucket, "agent-data");
|
||||
});
|
||||
|
||||
test("writes UTF-8 content to only the configured bucket", async () => {
|
||||
const client = new FakeClient(async () => ({ ETag: '"abc123"', VersionId: "v1" }));
|
||||
const result = await service(client).putObject({
|
||||
key: "reports/result.json",
|
||||
content: "{\"ok\":true}",
|
||||
contentType: "application/json",
|
||||
metadata: { run: "42" }
|
||||
});
|
||||
|
||||
assert.equal(client.commands.length, 1);
|
||||
assert.ok(client.commands[0] instanceof PutObjectCommand);
|
||||
assert.equal(client.commands[0].input.Bucket, "agent-data");
|
||||
assert.equal(client.commands[0].input.Key, "reports/result.json");
|
||||
assert.equal(client.commands[0].input.Body.toString("utf8"), '{"ok":true}');
|
||||
assert.equal(client.commands[0].input.ContentType, "application/json");
|
||||
assert.deepEqual(client.commands[0].input.Metadata, { run: "42" });
|
||||
assert.deepEqual(result, {
|
||||
bucket: "agent-data",
|
||||
key: "reports/result.json",
|
||||
size: 11,
|
||||
contentType: "application/json",
|
||||
etag: "abc123",
|
||||
versionId: "v1"
|
||||
});
|
||||
});
|
||||
|
||||
test("writes canonical base64 and enforces the object-size limit", async () => {
|
||||
const client = new FakeClient(async () => ({}));
|
||||
await service(client, 3).putObject({ key: "binary.dat", contentBase64: "AAEC" });
|
||||
assert.deepEqual([...client.commands[0].input.Body], [0, 1, 2]);
|
||||
|
||||
await assert.rejects(
|
||||
service(client, 2).putObject({ key: "large.dat", contentBase64: "AAEC" }),
|
||||
/write limit/
|
||||
);
|
||||
await assert.rejects(
|
||||
service(client).putObject({ key: "bad.dat", contentBase64: "not base64" }),
|
||||
/canonical base64/
|
||||
);
|
||||
await assert.rejects(
|
||||
service(client).putObject({ key: "ambiguous", content: "text", contentBase64: "dGV4dA==" }),
|
||||
/exactly one/
|
||||
);
|
||||
});
|
||||
|
||||
test("reads UTF-8 and binary objects with bounded streaming", async () => {
|
||||
const client = new FakeClient(async (command) => {
|
||||
assert.ok(command instanceof GetObjectCommand);
|
||||
return {
|
||||
Body: Readable.from([Buffer.from([0x68, 0x69])]),
|
||||
ContentLength: 2,
|
||||
ContentType: "text/plain",
|
||||
ETag: '"etag"',
|
||||
LastModified: new Date("2026-01-02T03:04:05Z"),
|
||||
Metadata: { source: "test" }
|
||||
};
|
||||
});
|
||||
|
||||
const text = await service(client).getObject({ key: "hello.txt" });
|
||||
assert.equal(text.content, "hi");
|
||||
assert.equal(text.encoding, "utf8");
|
||||
assert.equal(text.etag, "etag");
|
||||
assert.equal(text.lastModified, "2026-01-02T03:04:05.000Z");
|
||||
|
||||
const binary = await service(client).getObject({ key: "hello.txt", encoding: "base64" });
|
||||
assert.equal(binary.content, "aGk=");
|
||||
});
|
||||
|
||||
test("rejects oversized and invalid UTF-8 reads", async () => {
|
||||
const tooLarge = new FakeClient(async () => ({
|
||||
Body: Readable.from([Buffer.alloc(5)]),
|
||||
ContentLength: 5
|
||||
}));
|
||||
await assert.rejects(service(tooLarge, 4).getObject({ key: "large" }), /read limit/);
|
||||
|
||||
const invalidUtf8 = new FakeClient(async () => ({
|
||||
Body: Readable.from([Buffer.from([0xff])]),
|
||||
ContentLength: 1
|
||||
}));
|
||||
await assert.rejects(service(invalidUtf8).getObject({ key: "binary" }), /encoding=base64/);
|
||||
});
|
||||
|
||||
test("lists and stats objects without exposing another bucket argument", async () => {
|
||||
const client = new FakeClient(async (command) => {
|
||||
if (command instanceof ListObjectsV2Command) {
|
||||
return {
|
||||
Contents: [{ Key: "reports/a.txt", Size: 12, ETag: '"one"', LastModified: new Date("2026-02-03T00:00:00Z") }],
|
||||
CommonPrefixes: [{ Prefix: "reports/archive/" }],
|
||||
IsTruncated: true,
|
||||
NextContinuationToken: "next"
|
||||
};
|
||||
}
|
||||
assert.ok(command instanceof HeadObjectCommand);
|
||||
return { ContentLength: 12, ContentType: "text/plain", ETag: '"one"', Metadata: { owner: "agent" } };
|
||||
});
|
||||
|
||||
const listing = await service(client).listObjects({ prefix: "reports/", delimiter: "/", maxKeys: 10 });
|
||||
assert.equal(client.commands[0].input.Bucket, "agent-data");
|
||||
assert.equal(listing.objects[0].key, "reports/a.txt");
|
||||
assert.deepEqual(listing.commonPrefixes, ["reports/archive/"]);
|
||||
assert.equal(listing.nextContinuationToken, "next");
|
||||
|
||||
const stat = await service(client).statObject({ key: "reports/a.txt" });
|
||||
assert.equal(stat.size, 12);
|
||||
assert.deepEqual(stat.metadata, { owner: "agent" });
|
||||
await assert.rejects(service(client).statObject({ key: "x", bucket: "other" }), /Unknown argument: bucket/);
|
||||
});
|
||||
|
||||
test("returns bounded service errors without echoing the backend message", async () => {
|
||||
const backendError = Object.assign(new Error("request contained sensitive detail"), {
|
||||
name: "AccessDenied",
|
||||
$metadata: { httpStatusCode: 403, requestId: "req-123" }
|
||||
});
|
||||
const client = new FakeClient(async () => { throw backendError; });
|
||||
await assert.rejects(
|
||||
service(client).statObject({ key: "private.txt" }),
|
||||
(error) => error.message === "MinIO stat failed (AccessDenied, HTTP 403, request req-123)"
|
||||
);
|
||||
});
|
||||
|
||||
function s3Error(name, status) {
|
||||
return Object.assign(new Error(name), { name, $metadata: { httpStatusCode: status } });
|
||||
}
|
||||
|
||||
test("creates the bucket when it does not exist yet", async () => {
|
||||
const client = new FakeClient((command) => {
|
||||
if (command instanceof HeadBucketCommand) throw s3Error("NotFound", 404);
|
||||
return {};
|
||||
});
|
||||
assert.deepEqual(await service(client).ensureBucket(), { bucket: "agent-data", created: true });
|
||||
assert.ok(client.commands[1] instanceof CreateBucketCommand);
|
||||
assert.equal(client.commands[1].input.Bucket, "agent-data");
|
||||
});
|
||||
|
||||
test("leaves an existing bucket alone", async () => {
|
||||
const client = new FakeClient(() => ({}));
|
||||
assert.deepEqual(await service(client).ensureBucket(), { bucket: "agent-data", created: false });
|
||||
assert.equal(client.commands.length, 1);
|
||||
});
|
||||
|
||||
test("treats a bucket another session created first as existing", async () => {
|
||||
const client = new FakeClient((command) => {
|
||||
if (command instanceof HeadBucketCommand) throw s3Error("NotFound", 404);
|
||||
throw s3Error("BucketAlreadyOwnedByYou", 409);
|
||||
});
|
||||
assert.deepEqual(await service(client).ensureBucket(), { bucket: "agent-data", created: false });
|
||||
});
|
||||
|
||||
test("does not try to create a bucket it is denied access to", async () => {
|
||||
const client = new FakeClient(() => { throw s3Error("Forbidden", 403); });
|
||||
await assert.rejects(service(client).ensureBucket(), /MinIO connection failed \(Forbidden, HTTP 403\)/);
|
||||
assert.equal(client.commands.length, 1);
|
||||
});
|
||||
|
||||
test("leaves a shared client open when the service is destroyed", () => {
|
||||
let destroyed = false;
|
||||
const client = new FakeClient(() => ({}));
|
||||
client.destroy = () => { destroyed = true; };
|
||||
service(client).destroy();
|
||||
assert.equal(destroyed, false);
|
||||
});
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { startStreamableHttpServer } from "../src/streamable-http.js";
|
||||
|
||||
test("accepts a synchronous session cleanup callback during shutdown", async () => {
|
||||
const closed = [];
|
||||
const listener = await startStreamableHttpServer({
|
||||
serverName: "test-minio-mcp",
|
||||
serverVersion: "1.0.0",
|
||||
tools: [],
|
||||
callTool: async () => ({}),
|
||||
apiKeys: ["agent=0123456789abcdef0123456789abcdef"],
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
onSessionClose: ({ id }) => { closed.push(id); }
|
||||
});
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${listener.port}/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer 0123456789abcdef0123456789abcdef",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } }
|
||||
})
|
||||
});
|
||||
const sessionId = response.headers.get("mcp-session-id");
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(sessionId);
|
||||
|
||||
await listener.close();
|
||||
assert.deepEqual(closed, [sessionId]);
|
||||
});
|
||||
|
||||
const token = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
async function startPinned(options = {}) {
|
||||
return startStreamableHttpServer({
|
||||
serverName: "test-minio-mcp",
|
||||
serverVersion: "1.0.0",
|
||||
tools: (context) => context.pinned["x-minio-scope"] !== undefined ? [{ name: "internal_only" }] : [{ name: "external_only" }],
|
||||
callTool: async (name, _args, context) => ({ name, pinned: context.pinned }),
|
||||
apiKeys: [`agent=${token}`],
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pinnedHeaders: ["x-minio-scope"],
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
function rpc(listener, body, headers = {}) {
|
||||
return fetch(`http://127.0.0.1:${listener.port}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, ...body })
|
||||
});
|
||||
}
|
||||
|
||||
const initialize = { method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } } };
|
||||
|
||||
test("pins the bucket header at session open and offers that session's tools", async () => {
|
||||
const initialized = [];
|
||||
const listener = await startPinned({ onSessionInitialize: (context) => { initialized.push(context.pinned); } });
|
||||
try {
|
||||
const opened = await rpc(listener, initialize, { "x-minio-scope": "exec-1" });
|
||||
const sessionId = opened.headers.get("mcp-session-id");
|
||||
assert.equal(opened.status, 200);
|
||||
assert.deepEqual(initialized, [{ "x-minio-scope": "exec-1" }]);
|
||||
|
||||
const listed = await (await rpc(listener, { method: "tools/list" }, { "mcp-session-id": sessionId })).json();
|
||||
assert.deepEqual(listed.result.tools.map((tool) => tool.name), ["internal_only"]);
|
||||
|
||||
const called = await (await rpc(listener, { method: "tools/call", params: { name: "internal_only" } }, { "mcp-session-id": sessionId })).json();
|
||||
assert.deepEqual(called.result.structuredContent.pinned, { "x-minio-scope": "exec-1" });
|
||||
|
||||
const refused = await (await rpc(listener, { method: "tools/call", params: { name: "external_only" } }, { "mcp-session-id": sessionId })).json();
|
||||
assert.match(refused.error.message, /Unknown tool/);
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses a bucket header that changes within a session", async () => {
|
||||
const listener = await startPinned();
|
||||
try {
|
||||
const opened = await rpc(listener, initialize, { "x-minio-scope": "exec-1" });
|
||||
const sessionId = opened.headers.get("mcp-session-id");
|
||||
const changed = await rpc(listener, { method: "tools/list" }, { "mcp-session-id": sessionId, "x-minio-scope": "exec-2" });
|
||||
assert.equal(changed.status, 400);
|
||||
assert.match((await changed.json()).error, /cannot change within a session/);
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("offers the external tools to a session opened without the bucket header", async () => {
|
||||
const listener = await startPinned();
|
||||
try {
|
||||
const opened = await rpc(listener, initialize);
|
||||
const listed = await (await rpc(listener, { method: "tools/list" }, { "mcp-session-id": opened.headers.get("mcp-session-id") })).json();
|
||||
assert.deepEqual(listed.result.tools.map((tool) => tool.name), ["external_only"]);
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not open a session whose initialization fails", async () => {
|
||||
const closed = [];
|
||||
const listener = await startPinned({
|
||||
onSessionInitialize: () => { throw Object.assign(new Error("not a valid bucket name"), { statusCode: 400 }); },
|
||||
onSessionClose: ({ id }) => { closed.push(id); }
|
||||
});
|
||||
try {
|
||||
const refused = await rpc(listener, initialize, { "x-minio-scope": "Bad" });
|
||||
assert.equal(refused.status, 400);
|
||||
assert.match((await refused.json()).error, /Unable to initialize the session: not a valid bucket name/);
|
||||
assert.equal(refused.headers.get("mcp-session-id"), null);
|
||||
assert.equal(closed.length, 1);
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue