feat(mcp): expose storage.{describe,provision,inject_env} tools
The per-workspace GCS backend (bucket, service account, HMAC keys) was
already provisioned for P5.3 but wasn't reachable through MCP, so
agents using vibn_sk_* tokens couldn't actually use object storage.
Three new tools:
- storage.describe → bucket, region, endpoint, access_key_id.
No secret in response.
- storage.provision → idempotent ensureWorkspaceGcsProvisioned().
- storage.inject_env → writes STORAGE_* (or user-chosen prefix) env
vars into a Coolify app. SECRET_ACCESS_KEY is
tagged is_shown_once so Coolify masks it in
the UI, and it never leaves our backend — the
agent kicks off injection, but the HMAC secret
is read from our DB and pushed directly to
Coolify.
Apps can then hit the bucket with any S3 SDK (aws-sdk, boto3, etc.)
using force_path_style=true and the standard endpoint.
Made-with: Cursor
This commit is contained in:
@@ -20,6 +20,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
|
||||
import {
|
||||
ensureWorkspaceGcsProvisioned,
|
||||
getWorkspaceGcsState,
|
||||
getWorkspaceGcsHmacCredentials,
|
||||
} from '@/lib/workspace-gcs';
|
||||
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
|
||||
import {
|
||||
deployApplication,
|
||||
getApplicationInProject,
|
||||
@@ -107,6 +113,9 @@ export async function GET() {
|
||||
'domains.get',
|
||||
'domains.register',
|
||||
'domains.attach',
|
||||
'storage.describe',
|
||||
'storage.provision',
|
||||
'storage.inject_env',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -210,6 +219,13 @@ export async function POST(request: Request) {
|
||||
case 'domains.attach':
|
||||
return await toolDomainsAttach(principal, params);
|
||||
|
||||
case 'storage.describe':
|
||||
return await toolStorageDescribe(principal);
|
||||
case 'storage.provision':
|
||||
return await toolStorageProvision(principal);
|
||||
case 'storage.inject_env':
|
||||
return await toolStorageInjectEnv(principal, params);
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown tool "${action}"` },
|
||||
@@ -1139,3 +1155,133 @@ async function toolDomainsAttach(principal: Principal, params: Record<string, an
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Phase 5.3: Object storage (GCS via S3-compatible HMAC)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shape of the S3-compatible credentials we expose to agents.
|
||||
*
|
||||
* The HMAC *secret* is never returned here — only the access id and
|
||||
* the bucket/region/endpoint. Use `storage.inject_env` to push the
|
||||
* full `{accessId, secret}` pair into a Coolify app's env vars
|
||||
* server-side, where the secret never leaves our network.
|
||||
*/
|
||||
function describeWorkspaceStorage(ws: {
|
||||
slug: string;
|
||||
gcs_default_bucket_name: string | null;
|
||||
gcs_hmac_access_id: string | null;
|
||||
gcp_service_account_email: string | null;
|
||||
gcp_provision_status: string | null;
|
||||
}) {
|
||||
return {
|
||||
status: ws.gcp_provision_status ?? 'pending',
|
||||
bucket: ws.gcs_default_bucket_name,
|
||||
region: VIBN_GCS_LOCATION,
|
||||
endpoint: 'https://storage.googleapis.com',
|
||||
accessKeyId: ws.gcs_hmac_access_id,
|
||||
serviceAccountEmail: ws.gcp_service_account_email,
|
||||
note:
|
||||
'S3-compatible credentials. Use AWS SDKs with forcePathStyle=true and this endpoint. ' +
|
||||
'The secret access key is not returned here; call storage.inject_env to push it into a Coolify app.',
|
||||
};
|
||||
}
|
||||
|
||||
async function toolStorageDescribe(principal: Principal) {
|
||||
const ws = await getWorkspaceGcsState(principal.workspace.id);
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ result: describeWorkspaceStorage(ws) });
|
||||
}
|
||||
|
||||
async function toolStorageProvision(principal: Principal) {
|
||||
const result = await ensureWorkspaceGcsProvisioned(principal.workspace);
|
||||
return NextResponse.json({ result });
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the workspace's storage credentials into a Coolify app as
|
||||
* env vars, so the app can reach the bucket with any S3 SDK. The
|
||||
* HMAC secret is read server-side and written directly to Coolify —
|
||||
* it never transits through the agent or our API response.
|
||||
*
|
||||
* Envs written (all tagged is_shown_once so Coolify hides the secret
|
||||
* in the UI after first render):
|
||||
* STORAGE_ENDPOINT = https://storage.googleapis.com
|
||||
* STORAGE_REGION = northamerica-northeast1
|
||||
* STORAGE_BUCKET = vibn-ws-{slug}-{rand}
|
||||
* STORAGE_ACCESS_KEY_ID = GOOG1E... (HMAC access id)
|
||||
* STORAGE_SECRET_ACCESS_KEY = ... (HMAC secret — shown-once)
|
||||
* STORAGE_FORCE_PATH_STYLE = "true" (S3 SDKs need this for GCS)
|
||||
*
|
||||
* Agents can override the env var prefix via `params.prefix`
|
||||
* (e.g. "S3_" for apps that expect AWS-style names).
|
||||
*/
|
||||
async function toolStorageInjectEnv(principal: Principal, params: Record<string, any>) {
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
|
||||
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
|
||||
await getApplicationInProject(appUuid, projectUuid);
|
||||
|
||||
const prefix = String(params.prefix ?? 'STORAGE_');
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Param "prefix" must be uppercase ASCII (letters, digits, underscores)' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ws = await getWorkspaceGcsState(principal.workspace.id);
|
||||
if (!ws) return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
if (ws.gcp_provision_status !== 'ready' || !ws.gcs_default_bucket_name) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace storage not ready (status=${ws.gcp_provision_status}). Call storage.provision first.`,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const creds = getWorkspaceGcsHmacCredentials(ws);
|
||||
if (!creds) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Storage HMAC secret unavailable (pre-rotation key, or decrypt failed). Rotate and retry.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const entries: Array<{ key: string; value: string; shownOnce?: boolean }> = [
|
||||
{ key: `${prefix}ENDPOINT`, value: 'https://storage.googleapis.com' },
|
||||
{ key: `${prefix}REGION`, value: VIBN_GCS_LOCATION },
|
||||
{ key: `${prefix}BUCKET`, value: ws.gcs_default_bucket_name },
|
||||
{ key: `${prefix}ACCESS_KEY_ID`, value: creds.accessId },
|
||||
{ key: `${prefix}SECRET_ACCESS_KEY`, value: creds.secret, shownOnce: true },
|
||||
{ key: `${prefix}FORCE_PATH_STYLE`, value: 'true' },
|
||||
];
|
||||
|
||||
const written: string[] = [];
|
||||
const failed: Array<{ key: string; error: string }> = [];
|
||||
for (const e of entries) {
|
||||
try {
|
||||
await upsertApplicationEnv(appUuid, {
|
||||
key: e.key,
|
||||
value: e.value,
|
||||
is_shown_once: e.shownOnce ?? false,
|
||||
});
|
||||
written.push(e.key);
|
||||
} catch (err) {
|
||||
failed.push({ key: e.key, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
uuid: appUuid,
|
||||
prefix,
|
||||
written,
|
||||
failed: failed.length ? failed : undefined,
|
||||
bucket: ws.gcs_default_bucket_name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user