/**
* Shared Schema Cache
*
* Centralized caching for Attio workspace schema.
* Used by both MCP resource and get_workspace_schema tool.
*/
import { createAttioClient } from '../attio-client.js';
export interface AttributeSchema {
slug: string;
title: string;
type: string;
description?: string;
required: boolean;
is_array?: boolean;
options?: string[]; // For select fields
statuses?: string[]; // For status fields
}
export interface ObjectSchema {
api_slug: string;
title: string;
description: string;
type: 'object' | 'list';
total_attributes?: number; // Total count (added when filtered)
showing?: 'all' | 'key'; // What's being shown (added when filtered)
attributes: AttributeSchema[];
}
export interface WorkspaceSchema {
generated_at: string;
version: string;
view?: 'summary' | 'full'; // Added by filtering logic
objects: Record<string, ObjectSchema>;
lists?: Record<string, ObjectSchema>;
}
/**
* Module-level cache
*/
let cachedSchema: { data: WorkspaceSchema; timestamp: number } | null = null;
const CACHE_TTL = 3600000; // 1 hour
/**
* Fetch all objects in the workspace
*/
async function fetchAllObjects(client: any): Promise<any[]> {
const response = await client.get('/objects');
return response.data;
}
/**
* Fetch attributes for an object
*/
async function fetchObjectAttributes(
client: any,
objectSlug: string
): Promise<any[]> {
const response = await client.get(`/objects/${objectSlug}/attributes`);
return response.data;
}
/**
* Fetch options for a select field
*/
async function fetchSelectOptions(
client: any,
objectSlug: string,
attributeSlug: string
): Promise<string[]> {
try {
const response = await client.get(
`/objects/${objectSlug}/attributes/${attributeSlug}/options`
);
return response.data.map((opt: any) => opt.title);
} catch {
// Field might not have options endpoint (e.g., referenced object select fields)
return [];
}
}
/**
* Fetch statuses for a status field
*/
async function fetchStatusOptions(
client: any,
objectSlug: string,
attributeSlug: string
): Promise<string[]> {
try {
const response = await client.get(
`/objects/${objectSlug}/attributes/${attributeSlug}/statuses`
);
return response.data.map((status: any) => status.title);
} catch {
return [];
}
}
/**
* Generate light description for common field types
*/
function generateFieldDescription(
attr: any,
_objectSlug: string
): string | undefined {
const slug = attr.api_slug;
// Don't add descriptions for obvious fields
if (slug === 'record_id') return 'Unique identifier for this record';
if (slug === 'created_at') return 'Timestamp when record was created';
if (slug === 'updated_at') return 'Timestamp when record was last updated';
// Add light descriptions based on type
if (attr.type === 'record-reference') {
const targetObject = attr.relationship?.target_object || 'record';
return `Reference to a ${targetObject} record`;
}
if (attr.type === 'actor-reference') {
return 'User who performed this action';
}
// Otherwise, use the title as a hint (can be enhanced later)
return undefined;
}
/**
* Build schema for a single object
*/
async function buildObjectSchema(
client: any,
objectData: any
): Promise<ObjectSchema> {
const objectSlug = objectData.api_slug;
// Fetch attributes
const attributes = await fetchObjectAttributes(client, objectSlug);
// Build attribute schemas
const attributeSchemas: AttributeSchema[] = [];
for (const attr of attributes) {
const schema: AttributeSchema = {
slug: attr.api_slug,
title: attr.title,
type: attr.type,
required: attr.is_required || false,
};
// Add description
const description = generateFieldDescription(attr, objectSlug);
if (description) {
schema.description = description;
}
// Check if it's an array field
if (attr.is_multiselect || attr.is_list) {
schema.is_array = true;
}
// Fetch options for select fields
if (attr.type === 'select') {
const options = await fetchSelectOptions(
client,
objectSlug,
attr.api_slug
);
if (options.length > 0) {
schema.options = options;
}
}
// Fetch statuses for status fields
if (attr.type === 'status') {
const statuses = await fetchStatusOptions(
client,
objectSlug,
attr.api_slug
);
if (statuses.length > 0) {
schema.statuses = statuses;
}
}
attributeSchemas.push(schema);
}
// Generate object description
const title = objectData.plural_noun || objectData.name || objectSlug;
const objectDescription = objectData.description || title;
return {
api_slug: objectSlug,
title,
description: objectDescription,
type: objectData.type || 'object',
attributes: attributeSchemas,
};
}
/**
* Build complete workspace schema from Attio API
*/
async function buildWorkspaceSchema(apiKey: string): Promise<WorkspaceSchema> {
const client = createAttioClient(apiKey);
// Fetch all objects
const objects = await fetchAllObjects(client);
// Build schemas for each object
const objectSchemas: Record<string, ObjectSchema> = {};
for (const obj of objects) {
const schema = await buildObjectSchema(client, obj);
objectSchemas[obj.api_slug] = schema;
}
return {
generated_at: new Date().toISOString(),
version: '1.0.0',
objects: objectSchemas,
lists: {}, // TODO: Add lists support when needed
};
}
/**
* Check if an attribute is a "key" attribute (should be shown in summary)
*/
function isKeyAttribute(attr: AttributeSchema): boolean {
// ❌ Exclude metadata/system fields
const EXCLUDED = [
'record_id',
'created_at',
'updated_at',
'created_by',
'updated_by',
];
if (EXCLUDED.includes(attr.slug)) {
return false;
}
// ✅ Include required fields
if (attr.required) {
return true;
}
// ✅ Include select/status fields (user wants these options!)
if (attr.type === 'select' || attr.type === 'status') {
return true;
}
// ✅ Include relationships
if (attr.type === 'record-reference') {
return true;
}
// ✅ Include identifiers
const IDENTIFIERS = [
'name',
'display_name',
'full_name',
'first_name',
'last_name',
'nickname',
];
if (IDENTIFIERS.includes(attr.slug)) {
return true;
}
// ✅ Include contact/reference fields
const CONTACTS = ['email_addresses', 'domains', 'linkedin'];
if (CONTACTS.includes(attr.slug)) {
return true;
}
// ✅ Include description
if (attr.slug === 'description') {
return true;
}
// ✅ Include business metrics
if (
attr.slug.endsWith('_amount') ||
attr.slug.endsWith('_size') ||
attr.slug === 'round' ||
attr.slug === 'vintage_year' ||
attr.slug === 'discount'
) {
return true;
}
return false;
}
/**
* Filter workspace schema to only key attributes (for summary view)
*/
export function filterToKeyAttributes(
schema: WorkspaceSchema
): WorkspaceSchema {
const filteredObjects: Record<string, ObjectSchema> = {};
for (const [slug, obj] of Object.entries(schema.objects)) {
const totalAttributes = obj.attributes.length;
const keyAttributes = obj.attributes.filter(isKeyAttribute);
filteredObjects[slug] = {
...obj,
total_attributes: totalAttributes,
showing: 'key',
attributes: keyAttributes,
};
}
return {
...schema,
view: 'summary',
objects: filteredObjects,
};
}
/**
* Get workspace schema (with caching)
*
* @param apiKey - Attio API key
* @param forceReload - If true, bypass cache and fetch fresh from Attio
* @returns Complete workspace schema
*/
export async function getWorkspaceSchema(
apiKey: string,
forceReload: boolean = false
): Promise<WorkspaceSchema> {
const now = Date.now();
// Clear cache if force reload requested
if (forceReload) {
cachedSchema = null;
}
// Return cached if available and fresh
if (cachedSchema && now - cachedSchema.timestamp < CACHE_TTL) {
return cachedSchema.data;
}
// Build fresh schema
const schema = await buildWorkspaceSchema(apiKey);
// Cache it
cachedSchema = {
data: schema,
timestamp: now,
};
return schema;
}
/**
* Clear the schema cache
*/
export function clearSchemaCache(): void {
cachedSchema = null;
}