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 { NextResponse } from 'next/server';
|
||||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
|
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
|
||||||
|
import {
|
||||||
|
ensureWorkspaceGcsProvisioned,
|
||||||
|
getWorkspaceGcsState,
|
||||||
|
getWorkspaceGcsHmacCredentials,
|
||||||
|
} from '@/lib/workspace-gcs';
|
||||||
|
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
|
||||||
import {
|
import {
|
||||||
deployApplication,
|
deployApplication,
|
||||||
getApplicationInProject,
|
getApplicationInProject,
|
||||||
@@ -107,6 +113,9 @@ export async function GET() {
|
|||||||
'domains.get',
|
'domains.get',
|
||||||
'domains.register',
|
'domains.register',
|
||||||
'domains.attach',
|
'domains.attach',
|
||||||
|
'storage.describe',
|
||||||
|
'storage.provision',
|
||||||
|
'storage.inject_env',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -210,6 +219,13 @@ export async function POST(request: Request) {
|
|||||||
case 'domains.attach':
|
case 'domains.attach':
|
||||||
return await toolDomainsAttach(principal, params);
|
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:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Unknown tool "${action}"` },
|
{ 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