Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,41 @@ Model Context Protocol so any MCP-capable agent can drive them.

| Tool | Description |
|---|---|
| `action_new` | Create a new API endpoint (`__main__.py` + module file). `public` defaults to true. |
| `action_new` | Idempotently create an API endpoint (`__main__.py` + module file). `public` defaults to true. |
| `action_invoke` | Run `ops action invoke <endpoint>` with `key=value` params. |
| `action_requirements` | Add a library to an endpoint's `requirements.txt` (skips preinstalled libs). |
| `action_add_secret` | Wire a `.env` secret into an endpoint's context as `ctx.<SECRET>`. |
| `action_add_s3` | Add S3 to an endpoint's context (`ctx.S3_CLIENT`, `ctx.S3_DATA`, `ctx.S3_WEB`, `ctx.S3_PUBLIC`). |
| `secret_status` | Check secret presence and endpoint bindings without reading its value. |
| `secret_ensure` | Generate a missing `.env` secret without returning its value. |
| `secret_bind` | Atomically bind the same secret to multiple endpoints. |
| `secret_unbind` | Atomically remove an obsolete generated binding without reading or deleting the secret. |
| `auth_setup` | Prepare one shared secret for token-issuing and protected endpoints. |
| `action_add_s3` | Add bucket-scoped S3 to an endpoint's context (`ctx.S3_CLIENT`, `ctx.S3_DATA`, `ctx.S3_WEB`, `ctx.S3_PUBLIC`); forbids `list_buckets` and requires `put_object` → `get_object`/compare → `delete_object` for read/write verification. |
| `action_add_postgresql` | Add PostgreSQL (`ctx.POSTGRESQL`). |
| `action_add_redis` | Add Redis (`ctx.REDIS`, `ctx.REDIS_PREFIX`). |
| `action_add_redis` | Add Redis (`ctx.REDIS`, `ctx.REDIS_PREFIX`) and its Python runtime dependency. |
| `action_add_milvus` | Add Milvus vector DB (`ctx.MILVUS`). |
| `action_add_mongodb` | Add MongoDB (`ctx.MONGODB_CLIENT`, `ctx.MONGODB`). |

The `endpoint` argument is either `name` (uses the `v1` package) or `package/name`.
The `endpoint` argument is either `name` (uses the `v1` package) or
`package/name`. Both segments must start with a letter and contain only letters,
numbers, and hyphens. Use flat hyphenated names such as
`v1/employees-photo`; underscores, spaces, nested routes, and forms such as
`v1/employees_photo` or `v1/employees/photo` are invalid.
Repeating `action_new` for a compatible existing endpoint is a successful check
that leaves its files unchanged; incomplete paths and visibility conflicts are
reported as MCP errors.

Secret values are never returned by these tools. Missing secrets and invalid
endpoints are MCP errors (`isError: true`), so clients cannot mistake an
incomplete authentication setup for a successful tool call. `action_add_secret`
remains as the single-endpoint compatibility tool; use `secret_bind` or
`auth_setup` when several actions must share one credential.

When `OPENSERVERLESS_SECRETS_FILE` is set, `secret_ensure` also synchronizes the
requested name with that persistent env file. A value already present in the
working `.env` is copied without being returned; a persisted value is restored
to `.env` when needed. This lets hosts whose `.env` is generated keep app
secrets outside the source checkout.

## Working directory

Expand All @@ -33,6 +57,7 @@ opencode launches `type: "local"` MCP servers).
src/
index.ts entrypoint — registers every tool over stdio
lib.ts shared helpers (endpoint parsing, connector injection, types)
secrets.ts secret name, .env, status, and atomic binding helpers
tools/
new.ts action_new
invoke.ts action_invoke
Expand All @@ -43,6 +68,11 @@ src/
add-redis.ts action_add_redis
add-milvus.ts action_add_milvus
add-mongodb.ts action_add_mongodb
secret-status.ts secret_status
secret-ensure.ts secret_ensure
secret-bind.ts secret_bind
secret-unbind.ts secret_unbind
auth-setup.ts auth_setup
```

Each file under `tools/` default-exports a `Tool` (`defineTool({ name, config, handler })`);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"scripts": {
"start": "tsx src/index.ts",
"test": "tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
95 changes: 91 additions & 4 deletions spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ def main(args, ctx=None):

## returns

The resulting `<package>/<name>`.
The resulting `<package>/<name>`. Creation is idempotent: when a compatible
endpoint with `__main__.py` already exists, leave every file unchanged and
return a successful check/no-op result. An existing incomplete path or an
explicit `public` mismatch is a conflict and returns `isError: true`.

# tool action-invoke

Expand Down Expand Up @@ -105,19 +108,89 @@ Receive an <endpoint> (`name` or `package/name`) and a <secret> name.

This tool adds a new secret <MY_SECRET> to an endpoint/action/function.

First checks the secret is available in `.env`. If not, warn the user and suggest to add it to the environment.
First checks the secret is available in `.env`. If it is absent, return an MCP
error with `isError: true` and do not modify the endpoint.

Trustable-managed runtime variables are not application secrets and must be
rejected even when they exist in `.env`: `OPS_USER`, `OPS_PASSWORD`,
`OPS_APIHOST`, `OPS_REPO`, and `OPS_SKILLS`. In particular, the tool must never
generate `#--param OPS_APIHOST "$OPS_APIHOST"`; `OPS_APIHOST` belongs to the
Trustable/`ops ide` orchestration process, not to action runtime context.

- adds in `__main__.py` after "## build-context ##":

```
#--param <MY_SECRET> "$<MY_SECRET>"
builder.append(lambda args, ctx: setattr(ctx, '<MY_SECRET>', args.get("<MY_SECRET>", os.getenv("<MY_SECRET>"))))
def init_<my_secret>(args, ctx):
value = args.get("<MY_SECRET>") or os.getenv("<MY_SECRET>")
if not value:
raise RuntimeError("Required secret <MY_SECRET> is not configured")
setattr(ctx, "<MY_SECRET>", value)
builder.append(init_<my_secret>)
```

## returns

Information on the updated context.

# tool secret-status

Receive a secret name and an optional endpoint list. Report only whether the
name exists in `.env` and whether each generated wrapper contains the matching
parameter binding. Never read or return the value.

# tool secret-ensure

Receive an authorized exact secret name and optional random byte count. If the
name is absent, append a cryptographically random base64url value to `.env`.
If it already exists, leave it unchanged. Never return either an existing or a
new value.

When `OPENSERVERLESS_SECRETS_FILE` is configured, synchronize only the requested
name with that persistent env file. Prefer an existing persistent value, copy an
existing `.env` value into the persistent file when no persistent value exists,
and generate only when both are absent.

# tool secret-bind

Receive a secret name and a non-empty endpoint list. Validate the secret and
every wrapper before writing anything, then add the same strict `ctx.<SECRET>`
binding to every endpoint. The operation is idempotent and must not leave only
some endpoints configured when validation fails.

# tool secret-unbind

Receive one bound parameter name and a non-empty endpoint list. Validate every
generated wrapper before changing anything, then atomically remove only the
exact binding block produced by the secret tools. Do not read or delete the
value from `.env` or the persistent secret store. The operation is idempotent
and is the supported recovery path for legacy invalid bindings of
Trustable-managed variables such as `OPS_APIHOST`. When a binding is removed,
the tool must instruct the caller to recreate every changed endpoint with
`ops ide undeploy <endpoint>` followed by `ops ide deploy <endpoint>`, because
updating an existing OpenWhisk action without the parameter does not remove the
previously deployed binding. `ops ide clean` is insufficient because it removes
only local build artifacts.

# tool auth-setup

Receive a secret name, token-issuing endpoints, protected endpoints, and an
optional `generate_if_missing` flag. Ensure every action that creates or
validates authentication tokens receives the same secret. If generation is
requested, create the value without exposing it. The returned contract must
tell action code to use only `ctx.<SECRET>` and to fail closed instead of using
an environment or hardcoded fallback.

# MCP error semantics

Validation failures, missing files, failed child commands, and missing required
secrets return `isError: true`. Human-readable text beginning with `Error:` or
`Warning:` is not a substitute for MCP failure status.

Expected idempotent no-ops, including creating an endpoint that is already
present with compatible visibility, return normal successful results so MCP
clients render them as completed checks rather than failures.

# tool action-add-s3

## parameters
Expand All @@ -132,6 +205,14 @@ This tool adds S3 to the context of an endpoint/action/function, making availabl
- `ctx.S3_WEB` — the S3 web bucket (public)
- `ctx.S3_PUBLIC` — the public URL to access S3

The generated credentials are scoped to the configured application buckets.
Action code must never call `ctx.S3_CLIENT.list_buckets()`. Bucket listing,
`head_bucket`, or object listing is not proof of read/write access. A service
read/write check must create a unique temporary key in `ctx.S3_DATA` with
`put_object`, read it with `get_object`, compare the returned body bytes, and
remove it with `delete_object` in a `finally` block. It may report read/write
success only after the comparison succeeds.

- adds in `__main__.py` after "## build-context ##":

```
Expand Down Expand Up @@ -181,11 +262,17 @@ This tool adds a Redis connection to an endpoint/action/function, making availab
#--param REDIS_PREFIX "$REDIS_PREFIX"
import redis
def init_redis(args, ctx):
ctx.REDIS = redis.from_url(args.get("REDIS_URL", os.getenv("REDIS_URL")))
ctx.REDIS = redis.from_url(args.get("REDIS_URL", os.getenv("REDIS_URL")), decode_responses=True)
ctx.REDIS_PREFIX = args.get("REDIS_PREFIX", os.getenv("REDIS_PREFIX"))
builder.append(init_redis)
```

The tool must also add `redis` to the endpoint `requirements.txt`, because the
default Python action runtime does not guarantee that client library. Repeated
calls are idempotent and upgrade an existing generated Redis connector to
`decode_responses=True` so action results contain JSON-safe strings rather than
raw bytes.

## returns

Information on the updated context.
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import actionAddPostgresql from "./tools/add-postgresql.ts"
import actionAddRedis from "./tools/add-redis.ts"
import actionAddMilvus from "./tools/add-milvus.ts"
import actionAddMongodb from "./tools/add-mongodb.ts"
import secretEnsure from "./tools/secret-ensure.ts"
import secretStatus from "./tools/secret-status.ts"
import secretBind from "./tools/secret-bind.ts"
import secretUnbind from "./tools/secret-unbind.ts"
import authSetup from "./tools/auth-setup.ts"

const tools: Tool[] = [
actionNew,
Expand All @@ -40,6 +45,11 @@ const tools: Tool[] = [
actionAddRedis,
actionAddMilvus,
actionAddMongodb,
secretEnsure,
secretStatus,
secretBind,
secretUnbind,
authSetup,
]

const server = new McpServer({
Expand Down
35 changes: 31 additions & 4 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,23 @@ import { z, type ZodRawShape } from "zod"

export const BUILD_CONTEXT_MARKER = "## build-context ##"

const ACTION_SEGMENT = "[a-zA-Z][a-zA-Z0-9-]*"
export const ENDPOINT_PATTERN = new RegExp(`^(?:${ACTION_SEGMENT})(?:/${ACTION_SEGMENT})?$`)
const ENDPOINT_FORMAT =
"Use 'name' or 'package/name'. Each segment must start with a letter and contain only letters, numbers, and hyphens; underscores and spaces are invalid (example: 'v1/employees-photo')."

/** Shared `endpoint` argument used by every action tool. */
export const endpointArg = z
.string()
.describe("The endpoint path: 'name' (uses v1 package) or 'package/name'")
.trim()
.regex(ENDPOINT_PATTERN, ENDPOINT_FORMAT)
.describe(`The action endpoint. ${ENDPOINT_FORMAT}`)

/** Shape of a tool result returned to the MCP client. */
export interface ToolResult {
[x: string]: unknown
content: { type: "text"; text: string }[]
isError?: boolean
}

/**
Expand Down Expand Up @@ -69,10 +77,16 @@ export interface Endpoint {
* "package/name". Throws on malformed input (more than one "/").
*/
export function parseEndpoint(endpoint: string): Endpoint {
const parts = endpoint.trim().split("/")
if (parts.length > 2) {
throw new Error("endpoint must have at most one '/' (format: 'name' or 'package/name')")
const value = endpoint.trim()
if (!ENDPOINT_PATTERN.test(value)) {
const suggestion = value
.split("/")
.map((segment) => segment.replace(/[_\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""))
.join("/")
const hint = suggestion !== value && ENDPOINT_PATTERN.test(suggestion) ? ` Did you mean '${suggestion}'?` : ""
throw new Error(`invalid endpoint '${value}'. ${ENDPOINT_FORMAT}${hint}`)
}
const parts = value.split("/")
const pkg = parts.length === 1 ? "v1" : parts[0]
const name = parts.length === 1 ? parts[0] : parts[1]
const dir = join("packages", pkg, name)
Expand All @@ -84,6 +98,19 @@ export function text(message: string) {
return { content: [{ type: "text" as const, text: message }] }
}

/** A failed MCP tool result. Clients must not treat this as a completed call. */
export function error(message: string) {
return {
isError: true,
content: [{ type: "text" as const, text: message }],
}
}

/** Convert the status string returned by shared helpers into an MCP result. */
export function status(message: string) {
return message.startsWith("Error:") ? error(message) : text(message)
}

/**
* Inject a service connector into an endpoint's __main__.py at the
* `## build-context ##` marker.
Expand Down
Loading
Loading