diff --git a/README.md b/README.md index 5cd73bf..c00a469 100644 --- a/README.md +++ b/README.md @@ -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 ` 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.`. | -| `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 @@ -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 @@ -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 })`); diff --git a/package.json b/package.json index 9c6ff51..3eaaba1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "start": "tsx src/index.ts", + "test": "tsx --test tests/*.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/spec.md b/spec.md index c695e90..6d9b3b1 100644 --- a/spec.md +++ b/spec.md @@ -77,7 +77,10 @@ def main(args, ctx=None): ## returns -The resulting `/`. +The resulting `/`. 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 @@ -105,19 +108,89 @@ Receive an (`name` or `package/name`) and a name. This tool adds a new 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 "$" -builder.append(lambda args, ctx: setattr(ctx, '', args.get("", os.getenv("")))) +def init_(args, ctx): + value = args.get("") or os.getenv("") + if not value: + raise RuntimeError("Required secret is not configured") + setattr(ctx, "", value) +builder.append(init_) ``` ## 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.` +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 ` followed by `ops ide deploy `, 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.` 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 @@ -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 ##": ``` @@ -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. diff --git a/src/index.ts b/src/index.ts index b6a0d64..82b4ac8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -40,6 +45,11 @@ const tools: Tool[] = [ actionAddRedis, actionAddMilvus, actionAddMongodb, + secretEnsure, + secretStatus, + secretBind, + secretUnbind, + authSetup, ] const server = new McpServer({ diff --git a/src/lib.ts b/src/lib.ts index 7e089e7..35053e2 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -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 } /** @@ -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) @@ -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. diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..1848c14 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,282 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { randomBytes } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" +import { BUILD_CONTEXT_MARKER, parseEndpoint, type Endpoint } from "./lib.ts" + +const SECRET_NAME = /^[A-Z_][A-Z0-9_]*$/ +const TRUSTABLE_MANAGED_ENV_NAMES = new Set([ + "OPS_USER", + "OPS_PASSWORD", + "OPS_APIHOST", + "OPS_REPO", + "OPS_SKILLS", +]) + +export interface SecretBindingStatus { + endpoint: string + configured: boolean +} + +export interface SecretBindingResult { + configured: string[] + alreadyConfigured: string[] +} + +export interface SecretUnbindingResult { + removed: string[] + alreadyAbsent: string[] +} + +export interface EnsureEnvSecretResult { + created: boolean + persisted: boolean +} + +function normalizeEnvName(secret: string): string { + const name = secret.trim() + if (!SECRET_NAME.test(name)) { + throw new Error("secret name must contain only uppercase letters, numbers, and underscores, and must not start with a number") + } + return name +} + +export function normalizeSecretName(secret: string): string { + const name = normalizeEnvName(secret) + if (TRUSTABLE_MANAGED_ENV_NAMES.has(name)) { + throw new Error(`'${name}' is a Trustable-managed runtime variable and cannot be used as an application secret or action parameter`) + } + return name +} + +export function envNames(path = ".env"): Set { + return new Set(envValues(path).keys()) +} + +function envValues(path: string): Map { + if (!existsSync(path)) return new Map() + + const values = new Map() + for (const line of readFileSync(path, "utf-8").split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/) + if (match) values.set(match[1], match[2]) + } + return values +} + +function writeEnvValue(path: string, name: string, value: string): void { + const current = existsSync(path) ? readFileSync(path, "utf-8") : "" + const lines = current.split(/\r?\n/) + const assignment = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`) + let replaced = false + const next: string[] = [] + for (const [index, line] of lines.entries()) { + if (!assignment.test(line)) { + if (index < lines.length - 1 || line !== "") next.push(line) + continue + } + if (!replaced) next.push(`${name}=${value}`) + replaced = true + } + if (!replaced) next.push(`${name}=${value}`) + + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${next.join("\n")}\n`, { encoding: "utf-8", mode: 0o600 }) +} + +function persistentSecretsPath(): string | undefined { + const path = process.env.OPENSERVERLESS_SECRETS_FILE?.trim() + return path || undefined +} + +export function hasEnvSecret(secret: string, path = ".env"): boolean { + const name = normalizeSecretName(secret) + const local = envValues(path).get(name) + if (local) return true + const persistentPath = persistentSecretsPath() + return persistentPath ? Boolean(envValues(persistentPath).get(name)) : false +} + +/** + * Create an application-local secret without returning its value to the MCP + * client. Existing values are never read back, changed, or exposed. + */ +export function ensureEnvSecret(secret: string, bytes = 48, path = ".env"): EnsureEnvSecretResult { + const name = normalizeSecretName(secret) + const persistentPath = persistentSecretsPath() + const localValue = envValues(path).get(name) + const persistentValue = persistentPath ? envValues(persistentPath).get(name) : undefined + const created = !localValue && !persistentValue + const value = persistentValue || localValue || randomBytes(bytes).toString("base64url") + + if (localValue !== value) writeEnvValue(path, name, value) + if (persistentPath && persistentValue !== value) writeEnvValue(persistentPath, name, value) + + return { created, persisted: Boolean(persistentPath) } +} + +function secretInjection(secret: string): string { + return ` +#--param ${secret} "$${secret}" +def init_${secret.toLowerCase()}(args, ctx): + value = args.get("${secret}") or os.getenv("${secret}") + if not value: + raise RuntimeError("Required secret ${secret} is not configured") + setattr(ctx, "${secret}", value) +builder.append(init_${secret.toLowerCase()})` +} + +function endpointLabel(endpoint: Endpoint): string { + return `${endpoint.pkg}/${endpoint.name}` +} + +export function secretBindingStatus(secret: string, endpoints: string[]): SecretBindingStatus[] { + const name = normalizeSecretName(secret) + return endpoints.map((value) => { + const endpoint = parseEndpoint(value) + const configured = existsSync(endpoint.mainPath) + && readFileSync(endpoint.mainPath, "utf-8").includes(`#--param ${name} `) + return { endpoint: endpointLabel(endpoint), configured } + }) +} + +/** + * Bind one secret to every endpoint only after all endpoints have passed + * validation. This avoids a half-configured authentication flow. + */ +export function bindSecret(secret: string, endpointValues: string[]): SecretBindingResult { + const name = normalizeSecretName(secret) + if (!hasEnvSecret(name)) { + throw new Error(`secret '${name}' is not configured in .env; no endpoint was changed`) + } + if (endpointValues.length === 0) { + throw new Error("at least one endpoint is required") + } + + const endpoints = endpointValues.map(parseEndpoint) + const labels = endpoints.map(endpointLabel) + if (new Set(labels).size !== labels.length) { + throw new Error("endpoints must not contain duplicates") + } + + const configured: string[] = [] + const alreadyConfigured: string[] = [] + const changes: { path: string; previous: string; next: string }[] = [] + + for (const endpoint of endpoints) { + if (!existsSync(endpoint.mainPath)) { + throw new Error(`endpoint not found at ${endpoint.mainPath}; no endpoint was changed`) + } + + const previous = readFileSync(endpoint.mainPath, "utf-8") + if (!previous.includes(BUILD_CONTEXT_MARKER)) { + throw new Error(`marker '${BUILD_CONTEXT_MARKER}' not found in ${endpoint.mainPath}; no endpoint was changed`) + } + + const label = endpointLabel(endpoint) + if (previous.includes(`#--param ${name} `)) { + alreadyConfigured.push(label) + continue + } + + changes.push({ + path: endpoint.mainPath, + previous, + next: previous.replace(BUILD_CONTEXT_MARKER, BUILD_CONTEXT_MARKER + secretInjection(name)), + }) + configured.push(label) + } + + const written: typeof changes = [] + try { + for (const change of changes) { + writeFileSync(change.path, change.next) + written.push(change) + } + } catch (cause) { + for (const change of written.reverse()) { + writeFileSync(change.path, change.previous) + } + throw cause + } + + return { configured, alreadyConfigured } +} + +/** + * Remove only the exact wrapper block previously generated by the secret + * binding tools. This is also the supported recovery path for legacy invalid + * bindings of Trustable-managed variables such as OPS_APIHOST. + */ +export function unbindSecret(secret: string, endpointValues: string[]): SecretUnbindingResult { + const name = normalizeEnvName(secret) + if (endpointValues.length === 0) { + throw new Error("at least one endpoint is required") + } + + const endpoints = endpointValues.map(parseEndpoint) + const labels = endpoints.map(endpointLabel) + if (new Set(labels).size !== labels.length) { + throw new Error("endpoints must not contain duplicates") + } + + const injection = secretInjection(name) + const removed: string[] = [] + const alreadyAbsent: string[] = [] + const changes: { path: string; previous: string; next: string }[] = [] + + for (const endpoint of endpoints) { + if (!existsSync(endpoint.mainPath)) { + throw new Error(`endpoint not found at ${endpoint.mainPath}; no endpoint was changed`) + } + + const previous = readFileSync(endpoint.mainPath, "utf-8") + if (!previous.includes(BUILD_CONTEXT_MARKER)) { + throw new Error(`marker '${BUILD_CONTEXT_MARKER}' not found in ${endpoint.mainPath}; no endpoint was changed`) + } + + const label = endpointLabel(endpoint) + if (!previous.includes(injection)) { + alreadyAbsent.push(label) + continue + } + + changes.push({ + path: endpoint.mainPath, + previous, + next: previous.replace(injection, ""), + }) + removed.push(label) + } + + const written: typeof changes = [] + try { + for (const change of changes) { + writeFileSync(change.path, change.next) + written.push(change) + } + } catch (cause) { + for (const change of written.reverse()) { + writeFileSync(change.path, change.previous) + } + throw cause + } + + return { removed, alreadyAbsent } +} diff --git a/src/tools/add-milvus.ts b/src/tools/add-milvus.ts index 37e161c..341107e 100644 --- a/src/tools/add-milvus.ts +++ b/src/tools/add-milvus.ts @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { parseEndpoint, injectConnector, text, defineTool, endpointArg } from "../lib.ts" +import { parseEndpoint, injectConnector, error, status, defineTool, endpointArg } from "../lib.ts" export default defineTool({ name: "action_add_milvus", @@ -28,7 +28,7 @@ export default defineTool({ try { ep = parseEndpoint(endpoint) } catch (e) { - return text(`Error: ${(e as Error).message}`) + return error(`Error: ${(e as Error).message}`) } const injection = ` #--param MILVUS_HOST "$MILVUS_HOST" @@ -45,7 +45,7 @@ def init_milvus(args, ctx): ctx.MILVUS = MilvusClient(uri=uri, token=token, db_name=db_name) builder.append(init_milvus)` - return text( + return status( injectConnector({ endpoint: ep, label: "Milvus", diff --git a/src/tools/add-mongodb.ts b/src/tools/add-mongodb.ts index a49d971..2076207 100644 --- a/src/tools/add-mongodb.ts +++ b/src/tools/add-mongodb.ts @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { parseEndpoint, injectConnector, text, defineTool, endpointArg } from "../lib.ts" +import { parseEndpoint, injectConnector, error, status, defineTool, endpointArg } from "../lib.ts" export default defineTool({ name: "action_add_mongodb", @@ -28,7 +28,7 @@ export default defineTool({ try { ep = parseEndpoint(endpoint) } catch (e) { - return text(`Error: ${(e as Error).message}`) + return error(`Error: ${(e as Error).message}`) } const injection = ` #--param MONGODB_URI "$MONGODB_URI" @@ -41,7 +41,7 @@ def init_mongodb(args, ctx): ctx.MONGODB = ctx.MONGODB_CLIENT.get_default_database() builder.append(init_mongodb)` - return text( + return status( injectConnector({ endpoint: ep, label: "MongoDB", diff --git a/src/tools/add-postgresql.ts b/src/tools/add-postgresql.ts index 56f9d3c..d2539e9 100644 --- a/src/tools/add-postgresql.ts +++ b/src/tools/add-postgresql.ts @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { parseEndpoint, injectConnector, text, defineTool, endpointArg } from "../lib.ts" +import { parseEndpoint, injectConnector, error, status, defineTool, endpointArg } from "../lib.ts" export default defineTool({ name: "action_add_postgresql", @@ -28,7 +28,7 @@ export default defineTool({ try { ep = parseEndpoint(endpoint) } catch (e) { - return text(`Error: ${(e as Error).message}`) + return error(`Error: ${(e as Error).message}`) } const injection = ` #--param POSTGRES_URL "$POSTGRES_URL" @@ -38,7 +38,7 @@ def init_postgresql(args, ctx): ctx.POSTGRESQL = psycopg.connect(dburl) builder.append(init_postgresql)` - return text( + return status( injectConnector({ endpoint: ep, label: "PostgreSQL", diff --git a/src/tools/add-redis.ts b/src/tools/add-redis.ts index 0712222..63e07e4 100644 --- a/src/tools/add-redis.ts +++ b/src/tools/add-redis.ts @@ -15,7 +15,12 @@ // specific language governing permissions and limitations // under the License. -import { parseEndpoint, injectConnector, text, defineTool, endpointArg } from "../lib.ts" +import { readFileSync, writeFileSync } from "node:fs" +import { parseEndpoint, injectConnector, error, status, defineTool, endpointArg } from "../lib.ts" +import { ensurePythonRequirement } from "./requirements.ts" + +const LEGACY_CLIENT = 'redis.from_url(args.get("REDIS_URL", os.getenv("REDIS_URL")))' +const TEXT_CLIENT = 'redis.from_url(args.get("REDIS_URL", os.getenv("REDIS_URL")), decode_responses=True)' export default defineTool({ name: "action_add_redis", @@ -28,25 +33,33 @@ export default defineTool({ try { ep = parseEndpoint(endpoint) } catch (e) { - return text(`Error: ${(e as Error).message}`) + return error(`Error: ${(e as Error).message}`) } const injection = ` #--param REDIS_URL "$REDIS_URL" #--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)` - return text( - injectConnector({ - endpoint: ep, - label: "Redis", - guard: "init_redis", - injection, - available: " ctx.REDIS — the Redis client\n ctx.REDIS_PREFIX — the key prefix", - }), - ) + const connector = injectConnector({ + endpoint: ep, + label: "Redis", + guard: "init_redis", + injection, + available: " ctx.REDIS — the Redis client\n ctx.REDIS_PREFIX — the key prefix", + }) + if (connector.startsWith("Error:")) return status(connector) + + const wrapper = readFileSync(ep.mainPath, "utf-8") + if (wrapper.includes(LEGACY_CLIENT)) { + writeFileSync(ep.mainPath, wrapper.replace(LEGACY_CLIENT, TEXT_CLIENT)) + } + + const requirement = ensurePythonRequirement(ep.dir, "redis") + if (requirement.startsWith("Error:")) return status(requirement) + return status(`${connector}\n${requirement}`) }, }) diff --git a/src/tools/add-s3.ts b/src/tools/add-s3.ts index 91963aa..464b69f 100644 --- a/src/tools/add-s3.ts +++ b/src/tools/add-s3.ts @@ -15,12 +15,13 @@ // specific language governing permissions and limitations // under the License. -import { parseEndpoint, injectConnector, text, defineTool, endpointArg } from "../lib.ts" +import { parseEndpoint, injectConnector, error, status, defineTool, endpointArg } from "../lib.ts" export default defineTool({ name: "action_add_s3", config: { - description: "Add S3 connection to an endpoint's context. Provides ctx.S3_CLIENT, ctx.S3_DATA, ctx.S3_WEB, ctx.S3_PUBLIC.", + description: + "Add a bucket-scoped S3 connection to an endpoint's context. Provides ctx.S3_CLIENT, ctx.S3_DATA, ctx.S3_WEB, ctx.S3_PUBLIC. Never call list_buckets; verify read/write with put_object, get_object plus content comparison, and delete_object in ctx.S3_DATA.", inputSchema: { endpoint: endpointArg }, }, handler({ endpoint }) { @@ -28,7 +29,7 @@ export default defineTool({ try { ep = parseEndpoint(endpoint) } catch (e) { - return text(`Error: ${(e as Error).message}`) + return error(`Error: ${(e as Error).message}`) } const injection = ` #--param S3_HOST "$S3_HOST" @@ -53,14 +54,14 @@ def init_s3(args, ctx): ctx.S3_PUBLIC = args.get("S3_PUBLIC", os.getenv("OPSDEV_S3")) builder.append(init_s3)` - return text( + return status( injectConnector({ endpoint: ep, label: "S3", guard: "init_s3", injection, available: - " ctx.S3_CLIENT — the S3 client\n ctx.S3_DATA — the S3 data bucket (private)\n ctx.S3_WEB — the S3 web bucket (public)\n ctx.S3_PUBLIC — the public URL to access S3", + " ctx.S3_CLIENT — the bucket-scoped S3 client; never call list_buckets()\n ctx.S3_DATA — the S3 data bucket (private)\n ctx.S3_WEB — the S3 web bucket (public)\n ctx.S3_PUBLIC — the public URL to access S3\n\nVerification contract:\n Use ctx.S3_DATA with a unique temporary key. Call put_object, then get_object and compare the returned Body bytes, then delete_object in a finally block. Only report read/write success after the byte comparison succeeds. head_bucket or object/bucket listing proves neither read nor write access.", }), ) }, diff --git a/src/tools/add-secret.ts b/src/tools/add-secret.ts index 36a9bf8..9f38cae 100644 --- a/src/tools/add-secret.ts +++ b/src/tools/add-secret.ts @@ -15,9 +15,9 @@ // specific language governing permissions and limitations // under the License. -import { readFileSync, existsSync } from "node:fs" import { z } from "zod" -import { parseEndpoint, injectConnector, text, defineTool, endpointArg } from "../lib.ts" +import { error, text, defineTool, endpointArg } from "../lib.ts" +import { bindSecret, normalizeSecretName } from "../secrets.ts" export default defineTool({ name: "action_add_secret", @@ -29,34 +29,15 @@ export default defineTool({ }, }, handler({ endpoint, secret }) { - let ep try { - ep = parseEndpoint(endpoint) - } catch (e) { - return text(`Error: ${(e as Error).message}`) + const name = normalizeSecretName(secret) + const result = bindSecret(name, [endpoint]) + const target = result.configured[0] ?? result.alreadyConfigured[0] + return text(result.configured.length > 0 + ? `Added secret '${name}' to endpoint '${target}'. Available as ctx.${name}. The value was not returned.` + : `Secret '${name}' is already configured in endpoint '${target}'. The value was not read or returned.`) + } catch (cause) { + return error(`Secret binding failed: ${(cause as Error).message}`) } - const sec = secret.trim() - - let envContent = "" - if (existsSync(".env")) { - envContent = readFileSync(".env", "utf-8") - } - if (!envContent.includes(`${sec}=`)) { - return text(`Warning: secret '${sec}' not found in .env. Please add it to your environment before deploying.`) - } - - const injection = ` -#--param ${sec} "$${sec}" -builder.append(lambda args, ctx: setattr(ctx, '${sec}', args.get("${sec}", os.getenv("${sec}"))))` - - return text( - injectConnector({ - endpoint: ep, - label: `Secret '${sec}'`, - guard: `#--param ${sec} `, - injection, - available: ` ctx.${sec} — the secret value`, - }), - ) }, }) diff --git a/src/tools/auth-setup.ts b/src/tools/auth-setup.ts new file mode 100644 index 0000000..2b81150 --- /dev/null +++ b/src/tools/auth-setup.ts @@ -0,0 +1,59 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { z } from "zod" +import { defineTool, endpointArg, error, text } from "../lib.ts" +import { bindSecret, ensureEnvSecret, hasEnvSecret, normalizeSecretName } from "../secrets.ts" + +export default defineTool({ + name: "auth_setup", + config: { + description: "Prepare one shared secret for token-issuing and protected endpoints. Optionally generates a missing secret without exposing it, then binds every endpoint atomically.", + inputSchema: { + secret: z.string().describe("The exact application secret name (e.g. JWT_SECRET)"), + token_endpoints: z.array(endpointArg).min(1).describe("Endpoints that create or refresh authentication tokens, such as register and login"), + protected_endpoints: z.array(endpointArg).min(1).describe("Endpoints that validate authentication, including me/session and protected resources"), + generate_if_missing: z.boolean().optional().describe("Generate the secret in .env when absent. Defaults to false."), + }, + }, + handler({ secret, token_endpoints, protected_endpoints, generate_if_missing }) { + try { + const name = normalizeSecretName(secret) + let created = false + if (!hasEnvSecret(name)) { + if (!generate_if_missing) { + return error(`Authentication setup failed: secret '${name}' is not configured in .env. Call secret_ensure after the exact name is authorized, then retry auth_setup. No endpoint was changed.`) + } + created = ensureEnvSecret(name).created + } + + const endpoints = [...new Set([...token_endpoints, ...protected_endpoints])] + const result = bindSecret(name, endpoints) + const lines = [ + `Authentication secret '${name}' is ready for all ${endpoints.length} endpoints.`, + created ? "A new value was generated in .env and was not returned." : "The existing value was not read or returned.", + ] + if (result.configured.length > 0) lines.push(`Configured: ${result.configured.join(", ")}.`) + if (result.alreadyConfigured.length > 0) lines.push(`Already configured: ${result.alreadyConfigured.join(", ")}.`) + lines.push(`All token creation and validation code must use ctx.${name}; never os.getenv with a default or a hardcoded fallback.`) + lines.push("Registration must establish the same authenticated state as login. The frontend must validate persisted tokens through a protected me/session endpoint before rendering private pages.") + return text(lines.join("\n")) + } catch (cause) { + return error(`Authentication setup failed: ${(cause as Error).message}`) + } + }, +}) diff --git a/src/tools/invoke.ts b/src/tools/invoke.ts index b8056c6..75790cc 100644 --- a/src/tools/invoke.ts +++ b/src/tools/invoke.ts @@ -17,7 +17,7 @@ import { execFile } from "node:child_process" import { z } from "zod" -import { text, defineTool } from "../lib.ts" +import { error, text, defineTool } from "../lib.ts" export default defineTool({ name: "action_invoke", @@ -37,7 +37,7 @@ export default defineTool({ for (const kv of params) { const eqIdx = kv.indexOf("=") if (eqIdx === -1) { - return text(`Error: invalid parameter '${kv}', expected key=value format`) + return error(`Error: invalid parameter '${kv}', expected key=value format`) } paramArgs.push("-p", kv.substring(0, eqIdx), kv.substring(eqIdx + 1)) } @@ -60,6 +60,8 @@ export default defineTool({ if (stdout) result += stdout if (stderr) result += `\nSTDERR:\n${stderr}` if (exitCode !== 0) result += `\nExit code: ${exitCode}` - return text(result || "(no output)") + return exitCode === 0 + ? text(result || "(no output)") + : error(result || `ops action invoke failed with exit code ${exitCode}`) }, }) diff --git a/src/tools/new.ts b/src/tools/new.ts index 919cb94..6c11f9f 100644 --- a/src/tools/new.ts +++ b/src/tools/new.ts @@ -15,10 +15,10 @@ // specific language governing permissions and limitations // under the License. -import { mkdirSync, writeFileSync, existsSync } from "node:fs" +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs" import { join } from "node:path" import { z } from "zod" -import { parseEndpoint, text, defineTool, endpointArg } from "../lib.ts" +import { parseEndpoint, error, text, defineTool, endpointArg } from "../lib.ts" export default defineTool({ name: "action_new", @@ -37,23 +37,27 @@ export default defineTool({ try { ep = parseEndpoint(endpoint) } catch (e) { - return text(`Error: ${(e as Error).message}`) + return error(`Error: ${(e as Error).message}`) } const { pkg, name, dir } = ep - const validPattern = /^[a-zA-Z][a-zA-Z0-9-]*$/ - if (!validPattern.test(pkg)) { - return text(`Error: package '${pkg}' must only contain letters, numbers, '-' and start with a letter`) - } - if (!validPattern.test(name)) { - return text(`Error: name '${name}' must only contain letters, numbers, '-' and start with a letter`) - } - const isPublic = isPublicArg !== false const moduleName = name.replace(/-/g, "_") if (existsSync(dir)) { - return text(`Error: endpoint already exists at ${dir}`) + if (!existsSync(ep.mainPath)) { + return error(`Error: path already exists at ${dir}, but it is not a valid endpoint (__main__.py is missing)`) + } + + const existingMain = readFileSync(ep.mainPath, "utf-8") + const existingPublic = existingMain.match(/^#--web\s+(true|false)\s*$/m)?.[1] + if (existingPublic !== undefined && existingPublic !== String(isPublic)) { + return error( + `Error: endpoint already exists at ${dir} with public=${existingPublic}; requested public=${isPublic}`, + ) + } + + return text(`Check passed: endpoint already exists at ${dir}; no changes made.`) } mkdirSync(dir, { recursive: true }) diff --git a/src/tools/requirements.ts b/src/tools/requirements.ts index e99bab1..f173a14 100644 --- a/src/tools/requirements.ts +++ b/src/tools/requirements.ts @@ -18,16 +18,49 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs" import { join } from "node:path" import { z } from "zod" -import { parseEndpoint, text, defineTool, endpointArg } from "../lib.ts" +import { parseEndpoint, error, status, defineTool, endpointArg } from "../lib.ts" const PREINSTALLED = [ - "requests", "ollama", "openai", "pymilvus", "redis", "pyyaml", "boto3", + "requests", "ollama", "openai", "pymilvus", "pyyaml", "boto3", "psycopg", "beautifulsoup4", "pillow", "nltk", "httplib2", "kafka_python", "python-dateutil", "scrapy", "simplejson", "twisted", "netifaces", "pymongo", "minio", "langdetect", "plotly", "joblib", "lightgbm", "feedparser", "numpy", "scikit-learn", "langchain", "langchain-ollama", "langchain-openai", "bcrypt", ] +export function ensurePythonRequirement(dir: string, library: string): string { + const lib = library.trim() + if (!lib) return "Error: library name cannot be empty" + if (!existsSync(dir)) return `Error: endpoint not found at ${dir}` + + const normalizedLib = lib.toLowerCase().replace(/-/g, "_") + const isPreinstalled = PREINSTALLED.some( + (p) => p.toLowerCase().replace(/-/g, "_") === normalizedLib, + ) + if (isPreinstalled) { + return `Library '${lib}' is preinstalled and available. No action needed.` + } + + const reqPath = join(dir, "requirements.txt") + let existing = "" + if (existsSync(reqPath)) { + existing = readFileSync(reqPath, "utf-8") + } + + const lines = existing.split("\n").map((l) => l.trim()).filter(Boolean) + const alreadyAdded = lines.some( + (l) => l.toLowerCase().replace(/-/g, "_") === normalizedLib, + ) + if (alreadyAdded) { + return `Library '${lib}' is already in requirements.txt.` + } + + lines.push(lib) + writeFileSync(reqPath, lines.join("\n") + "\n") + + return `Added '${lib}' to ${reqPath}` +} + export default defineTool({ name: "action_requirements", config: { @@ -42,40 +75,8 @@ export default defineTool({ try { ep = parseEndpoint(endpoint) } catch (e) { - return text(`Error: ${(e as Error).message}`) - } - const lib = library.trim() - const { dir } = ep - - if (!existsSync(dir)) { - return text(`Error: endpoint not found at ${dir}`) + return error(`Error: ${(e as Error).message}`) } - - const normalizedLib = lib.toLowerCase().replace(/-/g, "_") - const isPreinstalled = PREINSTALLED.some( - (p) => p.toLowerCase().replace(/-/g, "_") === normalizedLib, - ) - if (isPreinstalled) { - return text(`Library '${lib}' is preinstalled and available. No action needed.`) - } - - const reqPath = join(dir, "requirements.txt") - let existing = "" - if (existsSync(reqPath)) { - existing = readFileSync(reqPath, "utf-8") - } - - const lines = existing.split("\n").map((l) => l.trim()).filter(Boolean) - const alreadyAdded = lines.some( - (l) => l.toLowerCase().replace(/-/g, "_") === normalizedLib, - ) - if (alreadyAdded) { - return text(`Library '${lib}' is already in requirements.txt.`) - } - - lines.push(lib) - writeFileSync(reqPath, lines.join("\n") + "\n") - - return text(`Added '${lib}' to ${reqPath}`) + return status(ensurePythonRequirement(ep.dir, library)) }, }) diff --git a/src/tools/secret-bind.ts b/src/tools/secret-bind.ts new file mode 100644 index 0000000..7a28acd --- /dev/null +++ b/src/tools/secret-bind.ts @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { z } from "zod" +import { defineTool, endpointArg, error, text } from "../lib.ts" +import { bindSecret, normalizeSecretName } from "../secrets.ts" + +export default defineTool({ + name: "secret_bind", + config: { + description: "Atomically bind one existing .env secret to multiple OpenServerless endpoints as ctx..", + inputSchema: { + secret: z.string().describe("The secret name (e.g. JWT_SECRET)"), + endpoints: z.array(endpointArg).min(1).describe("Every endpoint that must receive the same secret"), + }, + }, + handler({ secret, endpoints }) { + try { + const name = normalizeSecretName(secret) + const result = bindSecret(name, endpoints) + const lines = [`Secret '${name}' binding completed without exposing its value.`] + if (result.configured.length > 0) lines.push(`Configured: ${result.configured.join(", ")}.`) + if (result.alreadyConfigured.length > 0) lines.push(`Already configured: ${result.alreadyConfigured.join(", ")}.`) + lines.push(`Use ctx.${name} in action code and fail closed if it is unavailable; never use a hardcoded fallback.`) + return text(lines.join("\n")) + } catch (cause) { + return error(`Secret binding failed: ${(cause as Error).message}`) + } + }, +}) diff --git a/src/tools/secret-ensure.ts b/src/tools/secret-ensure.ts new file mode 100644 index 0000000..edb79a7 --- /dev/null +++ b/src/tools/secret-ensure.ts @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { z } from "zod" +import { defineTool, error, text } from "../lib.ts" +import { ensureEnvSecret, normalizeSecretName } from "../secrets.ts" + +export default defineTool({ + name: "secret_ensure", + config: { + description: "Create an application secret in .env only when absent. The value is generated securely and is never returned to the MCP client.", + inputSchema: { + secret: z.string().describe("The exact secret name authorized for this application (e.g. JWT_SECRET)"), + bytes: z.number().int().min(32).max(128).optional().describe("Random byte count. Defaults to 48."), + }, + }, + handler({ secret, bytes }) { + try { + const name = normalizeSecretName(secret) + const result = ensureEnvSecret(name, bytes ?? 48) + const persistence = result.persisted ? " It was synchronized with the configured persistent secret store." : "" + return text(result.created + ? `Created secret '${name}' in .env. Its value was not returned.${persistence}` + : `Secret '${name}' is already configured in .env. Its value was not returned.${persistence}`) + } catch (cause) { + return error(`Secret setup failed: ${(cause as Error).message}`) + } + }, +}) diff --git a/src/tools/secret-status.ts b/src/tools/secret-status.ts new file mode 100644 index 0000000..2474dbe --- /dev/null +++ b/src/tools/secret-status.ts @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { z } from "zod" +import { defineTool, endpointArg, error, text } from "../lib.ts" +import { hasEnvSecret, normalizeSecretName, secretBindingStatus } from "../secrets.ts" + +export default defineTool({ + name: "secret_status", + config: { + description: "Report whether a secret exists and is bound to selected endpoints without reading or returning its value.", + inputSchema: { + secret: z.string().describe("The secret name (e.g. JWT_SECRET)"), + endpoints: z.array(endpointArg).optional().describe("Optional endpoints whose secret binding should be checked"), + }, + }, + handler({ secret, endpoints }) { + try { + const name = normalizeSecretName(secret) + const present = hasEnvSecret(name) + const lines = [`Secret '${name}': ${present ? "configured in .env" : "not configured in .env"}.`] + for (const binding of secretBindingStatus(name, endpoints ?? [])) { + lines.push(`Endpoint '${binding.endpoint}': ${binding.configured ? "bound" : "not bound"}.`) + } + lines.push("No secret value was read or returned.") + return text(lines.join("\n")) + } catch (cause) { + return error(`Secret status failed: ${(cause as Error).message}`) + } + }, +}) diff --git a/src/tools/secret-unbind.ts b/src/tools/secret-unbind.ts new file mode 100644 index 0000000..9a586d4 --- /dev/null +++ b/src/tools/secret-unbind.ts @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { z } from "zod" +import { defineTool, endpointArg, error, text } from "../lib.ts" +import { unbindSecret } from "../secrets.ts" + +export default defineTool({ + name: "secret_unbind", + config: { + description: "Atomically remove one generated secret binding from multiple OpenServerless endpoints without deleting the secret value.", + inputSchema: { + secret: z.string().describe("The bound parameter name to remove"), + endpoints: z.array(endpointArg).min(1).describe("Every endpoint from which the generated binding must be removed"), + }, + }, + handler({ secret, endpoints }) { + try { + const result = unbindSecret(secret, endpoints) + const lines = [`Binding '${secret.trim()}' removal completed without reading or deleting its value.`] + if (result.removed.length > 0) lines.push(`Removed from: ${result.removed.join(", ")}.`) + if (result.alreadyAbsent.length > 0) lines.push(`Already absent from: ${result.alreadyAbsent.join(", ")}.`) + if (result.removed.length > 0) lines.push("Recreate every changed endpoint with `ops ide undeploy ` followed by `ops ide deploy `; an action update alone preserves previously deployed parameters.") + return text(lines.join("\n")) + } catch (cause) { + return error(`Secret unbinding failed: ${(cause as Error).message}`) + } + }, +}) diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts new file mode 100644 index 0000000..5956941 --- /dev/null +++ b/tests/secrets.test.ts @@ -0,0 +1,297 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import actionAddS3 from "../src/tools/add-s3.ts" +import actionAddSecret from "../src/tools/add-secret.ts" +import actionAddRedis from "../src/tools/add-redis.ts" +import actionNew from "../src/tools/new.ts" +import { endpointArg, parseEndpoint } from "../src/lib.ts" +import authSetup from "../src/tools/auth-setup.ts" +import secretBind from "../src/tools/secret-bind.ts" +import secretEnsure from "../src/tools/secret-ensure.ts" +import secretUnbind from "../src/tools/secret-unbind.ts" + +function resultText(result: { content: { text: string }[] }): string { + return result.content.map((part) => part.text).join("\n") +} + +function endpoint(path: string): string { + const [pkg, name] = path.split("/") + const dir = join("packages", pkg, name) + mkdirSync(dir, { recursive: true }) + const main = `#--kind python:default +import os +builder = [] +## build-context ## +` + writeFileSync(join(dir, "__main__.py"), main) + return main +} + +function inTemporaryProject(run: () => void): void { + const previous = process.cwd() + const directory = mkdtempSync(join(tmpdir(), "openserverless-mcp-test-")) + try { + process.chdir(directory) + run() + } finally { + process.chdir(previous) + rmSync(directory, { recursive: true, force: true }) + } +} + +test("secret_ensure creates an idempotent secret without returning its value", () => { + inTemporaryProject(() => { + const persistent = join(process.cwd(), "state", "secrets.env") + const previous = process.env.OPENSERVERLESS_SECRETS_FILE + process.env.OPENSERVERLESS_SECRETS_FILE = persistent + try { + const first = secretEnsure.handler({ secret: "JWT_SECRET", bytes: 32 }) + assert.equal(first.isError, undefined) + const env = readFileSync(".env", "utf-8") + const value = env.trim().split("=", 2)[1] + assert.ok(value.length >= 43) + assert.equal(resultText(first).includes(value), false) + assert.equal(readFileSync(persistent, "utf-8"), env) + + const second = secretEnsure.handler({ secret: "JWT_SECRET", bytes: 64 }) + assert.equal(second.isError, undefined) + assert.equal(readFileSync(".env", "utf-8"), env) + } finally { + if (previous === undefined) delete process.env.OPENSERVERLESS_SECRETS_FILE + else process.env.OPENSERVERLESS_SECRETS_FILE = previous + } + }) +}) + +test("action_add_secret reports a real MCP error when the secret is absent", () => { + inTemporaryProject(() => { + const original = endpoint("v1/login") + const result = actionAddSecret.handler({ endpoint: "v1/login", secret: "JWT_SECRET" }) + assert.equal(result.isError, true) + assert.match(resultText(result), /not configured in \.env/) + assert.equal(readFileSync("packages/v1/login/__main__.py", "utf-8"), original) + }) +}) + +test("secret tools reject Trustable-managed runtime variables", () => { + inTemporaryProject(() => { + writeFileSync(".env", "OPS_APIHOST=http://miniops.me\n") + const original = endpoint("v1/stack-status") + + const bind = actionAddSecret.handler({ + endpoint: "v1/stack-status", + secret: "OPS_APIHOST", + }) + assert.equal(bind.isError, true) + assert.match(resultText(bind), /Trustable-managed runtime variable/) + assert.equal(readFileSync("packages/v1/stack-status/__main__.py", "utf-8"), original) + + const ensure = secretEnsure.handler({ secret: "OPS_APIHOST", bytes: 32 }) + assert.equal(ensure.isError, true) + assert.match(resultText(ensure), /Trustable-managed runtime variable/) + assert.equal(readFileSync(".env", "utf-8"), "OPS_APIHOST=http://miniops.me\n") + }) +}) + +test("secret_unbind removes legacy managed bindings without reading env values", () => { + inTemporaryProject(() => { + const original = endpoint("v1/stack-status") + const injection = ` +#--param OPS_APIHOST "$OPS_APIHOST" +def init_ops_apihost(args, ctx): + value = args.get("OPS_APIHOST") or os.getenv("OPS_APIHOST") + if not value: + raise RuntimeError("Required secret OPS_APIHOST is not configured") + setattr(ctx, "OPS_APIHOST", value) +builder.append(init_ops_apihost)` + writeFileSync( + "packages/v1/stack-status/__main__.py", + original.replace("## build-context ##", "## build-context ##" + injection), + ) + + const result = secretUnbind.handler({ + secret: "OPS_APIHOST", + endpoints: ["v1/stack-status"], + }) + assert.equal(result.isError, undefined) + assert.match(resultText(result), /Removed from: v1\/stack-status/) + assert.match(resultText(result), /ops ide undeploy .*ops ide deploy /) + assert.equal(readFileSync("packages/v1/stack-status/__main__.py", "utf-8"), original) + + const repeated = secretUnbind.handler({ + secret: "OPS_APIHOST", + endpoints: ["v1/stack-status"], + }) + assert.equal(repeated.isError, undefined) + assert.match(resultText(repeated), /Already absent from: v1\/stack-status/) + }) +}) + +test("secret_bind validates every endpoint before changing any wrapper", () => { + inTemporaryProject(() => { + writeFileSync(".env", "JWT_SECRET=not-returned\n") + const original = endpoint("v1/login") + + const failed = secretBind.handler({ + secret: "JWT_SECRET", + endpoints: ["v1/login", "v1/me"], + }) + assert.equal(failed.isError, true) + assert.equal(readFileSync("packages/v1/login/__main__.py", "utf-8"), original) + + endpoint("v1/me") + const configured = secretBind.handler({ + secret: "JWT_SECRET", + endpoints: ["v1/login", "v1/me"], + }) + assert.equal(configured.isError, undefined) + assert.equal(resultText(configured).includes("not-returned"), false) + + for (const name of ["login", "me"]) { + const wrapper = readFileSync(`packages/v1/${name}/__main__.py`, "utf-8") + assert.match(wrapper, /#--param JWT_SECRET "\$JWT_SECRET"/) + assert.match(wrapper, /Required secret JWT_SECRET is not configured/) + assert.doesNotMatch(wrapper, /args\.get\("JWT_SECRET", os\.getenv/) + } + + const repeated = secretBind.handler({ + secret: "JWT_SECRET", + endpoints: ["v1/login", "v1/me"], + }) + assert.equal(repeated.isError, undefined) + assert.match(resultText(repeated), /Already configured: v1\/login, v1\/me/) + }) +}) + +test("auth_setup generates one undisclosed secret and binds token plus protected endpoints", () => { + inTemporaryProject(() => { + for (const name of ["register", "login", "me", "projects"]) endpoint(`v1/${name}`) + + const result = authSetup.handler({ + secret: "JWT_SECRET", + token_endpoints: ["v1/register", "v1/login"], + protected_endpoints: ["v1/me", "v1/projects"], + generate_if_missing: true, + }) + assert.equal(result.isError, undefined) + const value = readFileSync(".env", "utf-8").trim().split("=", 2)[1] + assert.equal(resultText(result).includes(value), false) + assert.match(resultText(result), /All token creation and validation code must use ctx\.JWT_SECRET/) + + for (const name of ["register", "login", "me", "projects"]) { + assert.match(readFileSync(`packages/v1/${name}/__main__.py`, "utf-8"), /#--param JWT_SECRET/) + } + }) +}) + +test("existing tools expose validation failures as MCP errors", () => { + inTemporaryProject(() => { + const result = actionNew.handler({ endpoint: "nested/path/value", public: true }) + assert.equal(result.isError, true) + }) +}) + +test("action_add_redis installs its runtime dependency and migrates text responses", () => { + inTemporaryProject(() => { + endpoint("v1/cache") + + const first = actionAddRedis.handler({ endpoint: "v1/cache" }) + assert.equal(first.isError, undefined) + assert.equal(readFileSync("packages/v1/cache/requirements.txt", "utf-8"), "redis\n") + assert.match( + readFileSync("packages/v1/cache/__main__.py", "utf-8"), + /redis\.from_url\(.+decode_responses=True\)/, + ) + + const repeated = actionAddRedis.handler({ endpoint: "v1/cache" }) + assert.equal(repeated.isError, undefined) + assert.match(resultText(repeated), /already in requirements\.txt/) + assert.equal(readFileSync("packages/v1/cache/requirements.txt", "utf-8"), "redis\n") + }) +}) + +test("action_add_s3 exposes the scoped-bucket read/write verification contract", () => { + inTemporaryProject(() => { + endpoint("v1/storage") + + const result = actionAddS3.handler({ endpoint: "v1/storage" }) + assert.equal(result.isError, undefined) + const output = resultText(result) + assert.match(output, /never call list_buckets\(\)/i) + assert.match(output, /ctx\.S3_DATA/) + assert.match(output, /put_object/) + assert.match(output, /get_object/) + assert.match(output, /compare the returned Body bytes/) + assert.match(output, /delete_object/) + assert.match(output, /head_bucket.*neither read nor write/i) + }) +}) + +test("action_new treats an existing compatible endpoint as a successful no-op", () => { + inTemporaryProject(() => { + const created = actionNew.handler({ endpoint: "v1/projects", public: true }) + assert.equal(created.isError, undefined) + + const modulePath = "packages/v1/projects/projects.py" + writeFileSync(modulePath, "# user implementation\n") + + const repeated = actionNew.handler({ endpoint: "v1/projects", public: true }) + assert.equal(repeated.isError, undefined) + assert.match(resultText(repeated), /Check passed: endpoint already exists/) + assert.equal(readFileSync(modulePath, "utf-8"), "# user implementation\n") + }) +}) + +test("action tools reject underscore endpoint names with a hyphenated suggestion", () => { + assert.equal(endpointArg.safeParse("v1/employees-photo").success, true) + const invalidSchema = endpointArg.safeParse("v1/employees_photo") + assert.equal(invalidSchema.success, false) + if (!invalidSchema.success) { + assert.match(invalidSchema.error.issues[0].message, /underscores and spaces are invalid/) + } + assert.throws(() => parseEndpoint("v1/employees_photo"), /Did you mean 'v1\/employees-photo'/) + + inTemporaryProject(() => { + const creation = actionNew.handler({ endpoint: "v1/employees_photo", public: true }) + assert.equal(creation.isError, true) + assert.match(resultText(creation), /Did you mean 'v1\/employees-photo'/) + + const connector = actionAddS3.handler({ endpoint: "v1/employees_photo" }) + assert.equal(connector.isError, true) + assert.match(resultText(connector), /Did you mean 'v1\/employees-photo'/) + }) +}) + +test("action_new reports incompatible existing paths as MCP errors", () => { + inTemporaryProject(() => { + mkdirSync("packages/v1/incomplete", { recursive: true }) + const incomplete = actionNew.handler({ endpoint: "v1/incomplete", public: true }) + assert.equal(incomplete.isError, true) + assert.match(resultText(incomplete), /not a valid endpoint/) + + actionNew.handler({ endpoint: "v1/private-action", public: false }) + const visibilityConflict = actionNew.handler({ endpoint: "v1/private-action", public: true }) + assert.equal(visibilityConflict.isError, true) + assert.match(resultText(visibilityConflict), /requested public=true/) + }) +})