Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/policy-engine-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@transcend-io/mcp-server-policy": minor
"@transcend-io/mcp": minor
---

Add Policy Engine MCP domain with `policy_help`, `policy_status`, `policy_publish`, and `policy_set_live`. Operations follow `transcend policy` CLI paths; OAuth requests only Activate Policy scope (superset of Manage/View).
7 changes: 7 additions & 0 deletions packages/mcp/mcp-server-policy/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @transcend-io/mcp-server-policy

## 0.1.0

### Minor Changes

- Initial release with Policy Engine MCP tools: `policy_help`, `policy_status`, `policy_publish`, and `policy_set_live`.
37 changes: 37 additions & 0 deletions packages/mcp/mcp-server-policy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# @transcend-io/mcp-server-policy

> **Beta** — this package is under active development. APIs may change without notice.

Transcend MCP Server for Policy Engine (Seneca). Provides tools to author OPA Rego policies, inspect bundle versions, publish inert revisions, and explicitly set them live.

Requires **Node.js ≥ 22.12** (see `engines` in `package.json`).

## Tools

| Tool | Description |
| ----------------- | ----------------------------------------------------------- |
| `policy_help` | Authoring guide and embedded starter templates (no network) |
| `policy_status` | List bundles, version history, presigned download URLs |
| `policy_publish` | Upload an inert version from a workspace directory |
| `policy_set_live` | Activate or deactivate a version (explicit go-live step) |

Operations mirror `transcend policy` CLI commands. Use a single credential with **Activate Policy** scope — it includes Manage and View.

## Install

```bash
npm install -g @transcend-io/mcp-server-policy
```

## Usage

```bash
TRANSCEND_OAUTH_CLIENT_ID=your-client-id \
TRANSCEND_OAUTH_CLIENT_SECRET=your-client-secret \
TRANSCEND_OAUTH_REDIRECT_PORT=your-client-redirect-port \
transcend-mcp-policy
```

**OAuth scopes:** `ActivatePolicyEngineBundles` (covers all policy tools). See [`src/scopes.ts`](./src/scopes.ts).

Full setup: [MCP root README](../README.md#oauth-client-setup).
58 changes: 58 additions & 0 deletions packages/mcp/mcp-server-policy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@transcend-io/mcp-server-policy",
"version": "0.1.0",
"description": "Transcend MCP Server — Policy Engine tools.",
"homepage": "https://github.com/transcend-io/tools/tree/main/packages/mcp/mcp-server-policy",
"license": "Apache-2.0",
"author": "Transcend Inc.",
"repository": {
"type": "git",
"url": "https://github.com/transcend-io/tools.git",
"directory": "packages/mcp/mcp-server-policy"
},
"bin": {
"transcend-mcp-policy": "./dist/cli.mjs"
},
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"types": "./dist/index.d.mts",
"exports": {
".": {
"@transcend-io/source": "./src/index.ts",
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"test": "vitest run --config ../../../vitest.config.mcp.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check:exports": "attw --pack . --ignore-rules cjs-resolves-to-esm",
"check:publint": "publint --level warning --strict --pack pnpm"
},
"dependencies": {
"@modelcontextprotocol/sdk": "catalog:",
"@transcend-io/mcp-server-base": "workspace:*",
"@transcend-io/privacy-types": "workspace:*",
"fast-glob": "^3.2.12",
"got": "^15.0.0",
"zod": "catalog:"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@types/node": "catalog:",
"publint": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"engines": {
"node": ">=22.12.0"
}
}
27 changes: 27 additions & 0 deletions packages/mcp/mcp-server-policy/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import {
createMCPServer,
createTranscendRestClient,
TranscendGraphQLBase,
} from '@transcend-io/mcp-server-base';

import packageJson from '../package.json' with { type: 'json' };
import { POLICY_OAUTH_SCOPES } from './scopes.js';
import { getPolicyTools } from './tools/index.js';

createMCPServer({
name: 'transcend-mcp-policy',
version: packageJson.version,
oauthScopes: POLICY_OAUTH_SCOPES,
getTools: getPolicyTools,
createClients: ({ auth, sombraUrl, sombraCustomerKey, graphqlUrl, dashboardUrl }) => {
const graphql = new TranscendGraphQLBase(auth, graphqlUrl);
return {
rest: createTranscendRestClient(auth, graphql, { sombraUrl, sombraCustomerKey }),
graphql,
dashboardUrl,
transcendApiUrl: graphqlUrl,
auth,
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import fs from 'node:fs';
import path from 'node:path';

/** Fields for building a policy bundle upload form. */
export interface BuildPolicyBundleFormDataOptions {
/** Absolute path to the bundle tarball */
bundlePath: string;
/** Version label */
version: string;
/** Optional description */
description?: string;
/** Bundle name (only for create) */
bundleName?: string;
}

/**
* Builds multipart form data for a policy bundle upload.
*
* @param options - Upload fields
* @returns FormData ready for POST
*/
export function buildPolicyBundleFormData(options: BuildPolicyBundleFormDataOptions): FormData {
const bundleBytes = fs.readFileSync(options.bundlePath);
const form = new FormData();
form.append(
'bundle',
new Blob([bundleBytes], { type: 'application/gzip' }),
path.basename(options.bundlePath),
);
form.append('version', options.version);
if (options.description) {
form.append('description', options.description);
}
if (options.bundleName) {
form.append('bundleName', options.bundleName);
}
return form;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { authHeaders, type AuthCredentials } from '@transcend-io/mcp-server-base';
import got, { type Got } from 'got';

/**
* Creates a got client for Policy Engine REST endpoints on the monolith.
*
* Mirrors {@link buildPolicyEngineClient} from `@transcend-io/cli` policy commands.
*
* @param transcendUrl - Transcend API base URL (without `/v1`)
* @param auth - MCP auth credentials (API key, OAuth token, or session cookie)
* @returns Configured got instance
*/
export function buildPolicyEngineClient(transcendUrl: string, auth: AuthCredentials): Got {
const normalized = transcendUrl.replace(/\/$/, '');
if (/(^|\/)v1$/i.test(normalized)) {
throw new Error(
`Transcend API URL must not include a trailing "/v1" (paths append it automatically). ` +
`Got "${transcendUrl}"; use "${normalized.replace(/\/v1$/i, '')}" instead.`,
);
}
return got.extend({
prefixUrl: normalized,
headers: {
...authHeaders(auth),
accept: 'application/json',
},
});
}
8 changes: 8 additions & 0 deletions packages/mcp/mcp-server-policy/src/helpers/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** Maximum compressed policy bundle upload size in bytes (5 KiB). */
export const MAX_BUNDLE_COMPRESSED_BYTES = 5120;

/** Maximum decompressed policy bundle size enforced by the server (50 KiB). */
export const MAX_BUNDLE_DECOMPRESSED_BYTES = 51200;

/** Placeholder for nullable API fields in summaries. */
export const EMPTY_CELL = '-';
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Formats a date as `yyyy-mm-dd-hh-mm-ss` in UTC.
*
* @param date - Date to format
* @returns Timestamp label
*/
function formatPolicyVersionTimestamp(date: Date): string {
return date.toISOString().slice(0, 19).replace(/[T:]/g, '-');
}

/**
* Returns a default version label from the bundle name and current UTC timestamp.
*
* @param bundleName - Tenant-unique policy bundle name
* @param now - Current time (for testing)
* @returns Version label in `{bundleName}-yyyy-mm-dd-hh-mm-ss` form
*/
export function defaultPolicyVersionLabel(bundleName: string, now: Date = new Date()): string {
return `${bundleName}-${formatPolicyVersionTimestamp(now)}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/** Parsed JSON error body from a Policy Engine API response. */
interface PolicyEngineErrorBody {
/** Human-readable error message */
message?: string;
}

/** HTTP response metadata on a got HTTPError. */
interface PolicyEngineHttpResponse {
/** HTTP status code */
statusCode?: number;
/** Response body (JSON or raw text) */
body?: unknown;
/** Response headers */
headers?: Record<string, string | string[] | undefined>;
}

/** Shape of a got HTTPError with response metadata. */
interface PolicyEngineHttpError {
/** HTTP response metadata */
response?: PolicyEngineHttpResponse;
}

/**
* Extracts a human-readable message from a Policy Engine API error body.
*
* @param body - Raw or parsed response body
* @returns API message when present
*/
function extractApiMessage(body: unknown): string | undefined {
if (typeof body === 'string') {
try {
const parsed = JSON.parse(body) as PolicyEngineErrorBody;
return parsed.message;
} catch {
return body.length > 0 ? body : undefined;
}
}

if (body && typeof body === 'object' && 'message' in body) {
const message = (body as PolicyEngineErrorBody).message;
if (typeof message === 'string' && message.length > 0) {
return message;
}
}

return undefined;
}

/**
* Maps common HTTP status codes to actionable messages.
*
* @param statusCode - HTTP status code
* @param apiMessage - Message from the API response body, when present
* @returns User-readable error text
*/
function formatHttpStatusError(statusCode: number, apiMessage?: string): string {
switch (statusCode) {
case 401:
return 'Authentication failed (401). Verify your API key or OAuth token has Policy Engine scopes.';
case 403:
return apiMessage ?? 'Access was denied (403 Forbidden).';
case 404:
return (
apiMessage ??
'Policy bundle or version not found. Use policy_status to list bundles and versions.'
);
case 400:
return apiMessage ?? 'The request was invalid. Check your inputs and try again.';
case 409:
return apiMessage ?? 'The request conflicted with the current policy bundle state.';
case 413:
return (
apiMessage ??
'Policy bundle upload is too large (max 5 KiB compressed, 50 KiB decompressed).'
);
case 429:
return apiMessage ?? 'Rate limit exceeded (429). Wait and retry.';
default:
if (statusCode >= 500) {
return `Transcend server error (${statusCode}). Try again in a few moments.`;
}
return apiMessage ?? `Request failed with status code ${statusCode}.`;
}
}

/**
* Returns true when the error looks like a network or timeout failure.
*
* @param error - Thrown error
* @returns Whether the error is likely a connectivity issue
*/
function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}

const code = (error as NodeJS.ErrnoException).code;
if (
code === 'ECONNREFUSED' ||
code === 'ENOTFOUND' ||
code === 'ETIMEDOUT' ||
code === 'ECONNRESET'
) {
return true;
}

const message = error.message.toLowerCase();
return (
message.includes('network') ||
message.includes('timeout') ||
message.includes('econnrefused') ||
message.includes('enotfound') ||
message.includes('etimedout')
);
}

/**
* Extracts a useful error message from a failed Policy Engine HTTP request.
*
* Adapted from `@transcend-io/cli` policy helpers.
*
* @param error - The thrown error, typically a got `HTTPError`
* @returns A message suitable for tool output
*/
export function formatPolicyEngineRequestError(error: unknown): string {
if (isNetworkError(error)) {
return 'Connection to Transcend failed. Check your network and TRANSCEND_API_URL.';
}

if (error && typeof error === 'object' && 'response' in error) {
const response = (error as PolicyEngineHttpError).response;
const statusCode = response?.statusCode;
const apiMessage = extractApiMessage(response?.body);

if (statusCode) {
return formatHttpStatusError(statusCode, apiMessage);
}

if (apiMessage) {
return apiMessage;
}
}

return error instanceof Error ? error.message : String(error);
}

/**
* Awaits a Policy Engine HTTP request and maps failures to user-readable errors.
*
* @param request - Promise returned by a got client call (e.g. `.json()`)
* @returns Parsed response body
*/
export async function policyEngineRequest<T>(request: Promise<T>): Promise<T> {
try {
return await request;
} catch (error) {
throw new Error(formatPolicyEngineRequestError(error), { cause: error });
}
}
Loading
Loading