diff --git a/README.md b/README.md index bbd65fb..179bcec 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ These tools are only listed and callable when write mode is enabled (see [Write - `get_legal_intakes` — List all legal intakes - `get_legal_intake_by_id` — Get specific legal intake by id +### Analytics + +- `get_analytics_catalog` — List allowlisted analytics tables and column schemas (GoogleSQL); optional `search` to narrow by table or column name/description +- `run_analytics_query` — Execute read-only GoogleSQL against analytics data; call `get_analytics_catalog` first when you need authoritative table/column names, types, or descriptions for this workspace + ### Other - `get_key_pointers` — Retrieves key pointers diff --git a/src/spotdraft_client.ts b/src/spotdraft_client.ts index ff2e157..67e4078 100644 --- a/src/spotdraft_client.ts +++ b/src/spotdraft_client.ts @@ -120,6 +120,20 @@ export class SpotDraftClient { return `SpotDraft API Error: ${statusText}`; } + /** Ensures we never silently follow redirects (POST would otherwise become GET). Caller must await before reading the body. */ + private async rejectIfRedirect(response: Response, method: string, url: string, endpoint: string): Promise { + if (response.status < 300 || response.status >= 400) { + return; + } + await response.arrayBuffer().catch(() => undefined); + const location = response.headers.get('location'); + throw new APIError( + `SpotDraft API returned HTTP ${response.status} redirect (${location ?? 'missing Location header'}). Set baseUrl to the canonical API URL so requests do not redirect (scheme, host, path, trailing slashes).`, + 502, + { endpoint, method, redirectStatus: response.status, location, requestUrl: url }, + ); + } + async get(endpoint: string, queryParams?: URLSearchParams) { const url = queryParams ? `${this.baseUrl}${endpoint}?${queryParams.toString()}` : `${this.baseUrl}${endpoint}`; @@ -129,8 +143,11 @@ export class SpotDraftClient { const response = await fetch(url, { method: 'GET', headers: this.headers, + redirect: 'manual', }); + await this.rejectIfRedirect(response, 'GET', url, endpoint); + if (!response.ok) { const errorBody = await response.text(); this.logger.error('SpotDraftClient', `API request failed`, undefined, { @@ -179,8 +196,11 @@ export class SpotDraftClient { method: 'POST', headers: this.headers, body: body ? JSON.stringify(body) : undefined, + redirect: 'manual', }); + await this.rejectIfRedirect(response, 'POST', url, endpoint); + if (!response.ok) { const errorBody = await response.text(); this.logger.error('SpotDraftClient', `API request failed`, undefined, { diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index ee2ac1d..97d779c 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -51,6 +51,9 @@ - Legal Intake: - `get_legal_intakes` - `get_legal_intake_by_id` +- Analytics: + - `get_analytics_catalog` + - `run_analytics_query` ## Registry Invariants diff --git a/src/tools/analytics/AGENTS.md b/src/tools/analytics/AGENTS.md new file mode 100644 index 0000000..e70331d --- /dev/null +++ b/src/tools/analytics/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md + +## Read This When + +- You are editing the analytics catalog tool, analytics query tool, embedded catalog summary, or changing the upstream analytics public API contracts. + +## Public Surface + +- `get_analytics_catalog.ts` — MCP tool and handler; `GET` `/v2.1/public/analytics/catalog/` with optional query `search`. +- `run_analytics_query.ts` — MCP tool and handler; forwards to SpotDraft with optional `user_scoped`. +- `analytics_query_docs.ts` — `buildRunAnalyticsQueryDescription()`: embedded OpenAPI-aligned schema summary (allowlisted tables, limits, errors), plus MCP-only guidance to call `get_analytics_catalog` for workspace schema discovery. **Keep the upstream-oriented paragraphs in sync** with `paths['/api/v2.1/public/analytics/query/'].post.description` in the regional `https:///api/docs/schema/` when the catalog copy changes. + +## Upstream + +- `GET` `/v2.1/public/analytics/catalog/` — tables and columns for analytics SQL; optional `search` query parameter (see `paths['/api/v2.1/public/analytics/catalog/'].get.description` in regional schema). +- `POST` `/v2.1/public/analytics/query/` — read-only GoogleSQL; request body `query` (required), `user_scoped` (optional). diff --git a/src/tools/analytics/analytics_query_docs.ts b/src/tools/analytics/analytics_query_docs.ts new file mode 100644 index 0000000..ac84c7b --- /dev/null +++ b/src/tools/analytics/analytics_query_docs.ts @@ -0,0 +1,67 @@ +/** + * Embedded summary for `run_analytics_query` tool descriptions. + * Keep in sync with `paths['/api/v2.1/public/analytics/query/'].post.description` + * in https://api.in.spotdraft.com/api/docs/schema/ when the deployment catalog changes. + */ + +const ANALYTICS_TABLE_CATALOG_URL = 'https://api.in.spotdraft.com/api/docs#description/analytics-query-table-catalog'; +const ANALYTICS_TAG_URL = 'https://api.in.spotdraft.com/api/docs#tag/v21-analytics-query'; + +/** Upstream OpenAPI operation description (Scalar-specific anchor link replaced with a public URL). */ +const ANALYTICS_QUERY_OPERATION_SUMMARY = `Runs **read-only** analytics SQL in **GoogleSQL** (Google Standard SQL), including for timestamps, dates, and functions. Submit exactly one **\`SELECT\`** statement (optional **\`WITH\`** / **\`UNION\`**). Inserts, updates, deletes, DDL, and multi-statement batches are rejected. + +**Enablement** +This capability must be enabled for your workspace. If it is not, the API returns **501** with code \`SD_PUBLIC_00045\`. Contact SpotDraft support to request access. + +**Permission** +**Insights view** (\`INSIGHTS_VIEW\`). + +**Contracts scoping** +By default, the **\`Contracts\`** table is limited to rows the authenticated user may access. Optional body field **\`user_scoped\`** or header **\`X-Spotdraft-Analytics-User-Scoped\`** (\`true\` / \`false\`, or \`1\` / \`0\`, \`yes\` / \`no\`) overrides that. When both are omitted, the default is **\`true\`** in this environment. **\`user_scoped: false\`** (or the header equivalent) allows workspace-wide contract rows only for users with the workspace **Admin** role; others receive **403** with \`SD_PUBLIC_00044\`. + +**Tables and columns** +Use **bare catalog table names** (e.g. \`Contracts\`, \`Users\`) and **snake_case** public column names from the table catalog (e.g. \`execution_time\`, \`workflow_id\`). Only allowlisted tables and columns are accepted. For this workspace’s live schema (and to avoid \`SD_PUBLIC_00041\` on bad table/column names), call the **\`get_analytics_catalog\`** MCP tool first; use optional \`search\` to filter tables or columns. + +**Allowlisted tables (quick reference)** + +| Table in SQL | Role | +|---|---| +| \`Contracts\` | Core contract fact table | +| \`Users\` | Users / org users | +| \`Workflows_setup\` | Workflow definitions (incl. frozen versions) | +| \`Contract_types_setup\` | Contract type lookup | +| \`Counterparties_setup\` | Counterparty directory | +| \`Approvals_Setup\` | Approval steps in workflows | +| \`Entities_setup\` | Legal entities | +| \`Signatures_setups\` | Signatory configuration per workflow | + +For full column lists, types, and join guidance, prefer **\`get_analytics_catalog\`**; the [SpotDraft public analytics table catalog](${ANALYTICS_TABLE_CATALOG_URL}) is supplementary documentation. + +**Limits (this deployment)** +- At most **5,000** rows per response; \`truncated\` is **true** when more rows would have matched. +- Query job timeout: **60 seconds**. +- Maximum bytes billed per query job: **500 MB** (500,000,000 bytes). +- SQL text length limit: **10,000** characters per request. + +**Errors** + +| Code | HTTP | Meaning | +|---|---:|---| +| \`SD_PUBLIC_00040\` | 400 | Not an allowed read-only SELECT, or more than one statement | +| \`SD_PUBLIC_00041\` | 400 | References a table outside the allowlisted catalog | +| \`SD_PUBLIC_00042\` | 400 | Query could not be parsed or transpiled safely | +| \`SD_PUBLIC_00043\` | 502 | Query execution failed | +| \`SD_PUBLIC_00044\` | 403 | Workspace-wide contracts requested without workspace Admin role | +| \`SD_PUBLIC_00045\` | 501 | Analytics query API not enabled for this workspace | + +[V2.1 Analytics Query API reference](${ANALYTICS_TAG_URL})`; + +export function buildRunAnalyticsQueryDescription(): string { + return `Execute read-only SpotDraft analytics SQL (GoogleSQL). + +**When to use:** Custom reporting, metrics, or joins across allowlisted analytics tables when contracts workspace analytics is enabled for your org. + +**Before writing SQL:** Call **\`get_analytics_catalog\`** when you need accurate table and column names, types, or descriptions for this workspace, or when exploring what is queryable. Use it before \`run_analytics_query\` whenever the schema is unclear. + +${ANALYTICS_QUERY_OPERATION_SUMMARY}`; +} diff --git a/src/tools/analytics/get_analytics_catalog.ts b/src/tools/analytics/get_analytics_catalog.ts new file mode 100644 index 0000000..10fae39 --- /dev/null +++ b/src/tools/analytics/get_analytics_catalog.ts @@ -0,0 +1,33 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { SpotDraftClient } from '../../spotdraft_client.js'; + +const getAnalyticsCatalogTool: Tool = { + name: 'get_analytics_catalog', + description: `Returns allowlisted analytics tables and column schemas for GoogleSQL used with run_analytics_query. Requires Insights view (INSIGHTS_VIEW). Optional search narrows results (case-insensitive match on table/column names and descriptions). Analytics query API must be enabled for the workspace or upstream returns 501.`, + inputSchema: { + type: 'object', + properties: { + search: { + type: 'string', + description: + 'Optional filter: case-insensitive substring match on table names, table descriptions, column names, and column descriptions.', + }, + }, + }, +}; + +export interface GetAnalyticsCatalogRequest { + search?: string; +} + +const getAnalyticsCatalog = async ( + request: GetAnalyticsCatalogRequest, + spotdraftClient: SpotDraftClient, +): Promise => { + const endpoint = '/v2.1/public/analytics/catalog/'; + const search = request.search?.trim(); + const queryParams = search !== undefined && search !== '' ? new URLSearchParams({ search }) : undefined; + return spotdraftClient.get(endpoint, queryParams); +}; + +export { getAnalyticsCatalog, getAnalyticsCatalogTool }; diff --git a/src/tools/analytics/run_analytics_query.ts b/src/tools/analytics/run_analytics_query.ts new file mode 100644 index 0000000..a01e47a --- /dev/null +++ b/src/tools/analytics/run_analytics_query.ts @@ -0,0 +1,57 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { ValidationError } from '../../errors/index.js'; +import { SpotDraftClient } from '../../spotdraft_client.js'; +import { buildRunAnalyticsQueryDescription } from './analytics_query_docs.js'; + +const MAX_QUERY_LENGTH = 10000; + +const runAnalyticsQueryTool: Tool = { + name: 'run_analytics_query', + description: buildRunAnalyticsQueryDescription(), + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'A single GoogleSQL SELECT (optional WITH / UNION). Max 10,000 characters. Only allowlisted tables/columns; see tool description.', + }, + user_scoped: { + type: 'boolean', + description: + 'When true (default when omitted), restrict Contracts to rows the caller may access. When false, workspace-wide Contracts only for workspace Admin; others get 403.', + }, + }, + required: ['query'], + }, +}; + +export interface RunAnalyticsQueryRequest { + query: string; + user_scoped?: boolean; +} + +const runAnalyticsQuery = async ( + request: RunAnalyticsQueryRequest, + spotdraftClient: SpotDraftClient, +): Promise => { + const query = request.query?.trim() ?? ''; + if (query === '') { + throw new ValidationError('query is required and cannot be empty.', { field: 'query' }); + } + if (query.length > MAX_QUERY_LENGTH) { + throw new ValidationError(`query exceeds maximum length of ${MAX_QUERY_LENGTH} characters.`, { + field: 'query', + length: query.length, + }); + } + + const body: { query: string; user_scoped?: boolean } = { query }; + if (typeof request.user_scoped === 'boolean') { + body.user_scoped = request.user_scoped; + } + + return spotdraftClient.post('/v2.1/public/analytics/query', body); +}; + +export { runAnalyticsQuery, runAnalyticsQueryTool }; diff --git a/src/tools/index.ts b/src/tools/index.ts index 3139f09..c13b2c0 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -49,6 +49,10 @@ import { getTemplateDetails, getTemplateDetailsTool } from './templates/get_temp import { getTemplateMetadata, getTemplateMetadataTool } from './templates/get_template_metadata.js'; import { getTemplates, getTemplatesTool } from './templates/get_templates.js'; +// Analytics tools +import { getAnalyticsCatalog, getAnalyticsCatalogTool } from './analytics/get_analytics_catalog.js'; +import { runAnalyticsQuery, runAnalyticsQueryTool } from './analytics/run_analytics_query.js'; + /** * Global tool registry instance * All tools are registered here for centralized management @@ -159,6 +163,16 @@ toolRegistry.register({ handler: withSpotDraftClient(getLegalIntakeById), }); +toolRegistry.register({ + tool: getAnalyticsCatalogTool, + handler: withSpotDraftClient(getAnalyticsCatalog), +}); + +toolRegistry.register({ + tool: runAnalyticsQueryTool, + handler: withSpotDraftClient(runAnalyticsQuery), +}); + // Write tools (only available when write mode is enabled) toolRegistry.register({ tool: uploadContractVersionTool,