Skip to main content

cedros_data/http_discovery/
content.rs

1//! Composable content generators for AI discovery
2//!
3//! Exports functions that generate AI discovery content without being tied to
4//! HTTP handlers, allowing consuming applications to compose unified discovery
5//! from multiple cedros packages.
6
7use super::types::*;
8
9/// Configuration for content generation.
10///
11/// For cedros-data the base path is always `""` (root-mounted).
12#[derive(Debug, Clone)]
13pub struct ContentConfig {
14    pub base_path: String,
15    pub name: String,
16    pub version: String,
17    pub description: String,
18    pub homepage: Option<String>,
19}
20
21impl ContentConfig {
22    pub fn new(base_path: &str) -> Self {
23        Self {
24            base_path: base_path.to_string(),
25            name: "cedros-data".to_string(),
26            version: env!("CARGO_PKG_VERSION").to_string(),
27            description: "Multi-site Postgres-backed content and custom data storage API"
28                .to_string(),
29            homepage: None,
30        }
31    }
32
33    pub fn path(&self, endpoint: &str) -> String {
34        format!("{}{}", self.base_path, endpoint)
35    }
36}
37
38impl Default for ContentConfig {
39    fn default() -> Self {
40        Self::new("")
41    }
42}
43
44// ============================================================================
45// Skill Definitions
46// ============================================================================
47
48pub fn get_skill_references(config: &ContentConfig) -> Vec<SkillReference> {
49    vec![
50        SkillReference {
51            id: "data".to_string(),
52            name: "Data".to_string(),
53            path: config.path("/skills/data.md"),
54            description: "Entry upsert, query, and collection management".to_string(),
55            requires_auth: Some(true),
56            requires_admin: None,
57        },
58        SkillReference {
59            id: "admin".to_string(),
60            name: "Admin".to_string(),
61            path: config.path("/skills/admin.md"),
62            description: "Site bootstrap, collection admin, page management".to_string(),
63            requires_auth: Some(true),
64            requires_admin: Some(true),
65        },
66        SkillReference {
67            id: "schema".to_string(),
68            name: "Schema".to_string(),
69            path: config.path("/skills/schema.md"),
70            description: "Custom schemas, contract verification, import/export".to_string(),
71            requires_auth: Some(true),
72            requires_admin: None,
73        },
74        SkillReference {
75            id: "storage".to_string(),
76            name: "Storage".to_string(),
77            path: config.path("/skills/storage.md"),
78            description: "Media upload, asset management, S3-compatible storage".to_string(),
79            requires_auth: Some(true),
80            requires_admin: Some(true),
81        },
82        SkillReference {
83            id: "site".to_string(),
84            name: "Site".to_string(),
85            path: config.path("/skills/site.md"),
86            description: "Site registration, migration, public config".to_string(),
87            requires_auth: Some(true),
88            requires_admin: None,
89        },
90    ]
91}
92
93pub fn get_skill_capabilities() -> SkillCapabilities {
94    SkillCapabilities {
95        multi_site: true,
96        collections: true,
97        custom_schema: true,
98        typed_tables: true,
99        contracts: true,
100        import_export: true,
101        default_pages: true,
102        media_storage: true,
103        metered_reads: true,
104    }
105}
106
107pub fn get_skill_auth() -> SkillAuth {
108    SkillAuth {
109        methods: vec![
110            "bearer-token".to_string(),
111            "x-cedros-org-id".to_string(),
112        ],
113        recommended: "bearer-token".to_string(),
114        header: "Authorization".to_string(),
115    }
116}
117
118pub fn get_rate_limits() -> RateLimits {
119    RateLimits {
120        api_endpoints: "100 req/min per key".to_string(),
121        admin_endpoints: "30 req/min per key".to_string(),
122    }
123}
124
125pub fn get_skill_metadata(config: &ContentConfig) -> SkillMetadata {
126    SkillMetadata {
127        name: config.name.clone(),
128        version: config.version.clone(),
129        description: config.description.clone(),
130        homepage: config.homepage.clone(),
131        api_base: config.base_path.clone(),
132        category: "data-storage".to_string(),
133        capabilities: get_skill_capabilities(),
134        skills: get_skill_references(config),
135        authentication: get_skill_auth(),
136        rate_limits: get_rate_limits(),
137        downloadable_bundles: Some(DownloadableBundles {
138            claude_code: config.path("/.well-known/skills.zip"),
139            codex: config.path("/.well-known/skills.zip"),
140        }),
141    }
142}
143
144// ============================================================================
145// Discovery Index
146// ============================================================================
147
148pub fn get_discovery_index(config: &ContentConfig) -> AiDiscoveryIndex {
149    AiDiscoveryIndex {
150        version: "1.0.0".to_string(),
151        name: config.name.clone(),
152        description: config.description.clone(),
153        endpoints: DiscoveryEndpoints {
154            llms_txt: config.path("/llms.txt"),
155            llms_full_txt: config.path("/llms-full.txt"),
156            llms_admin_txt: Some(config.path("/llms-admin.txt")),
157            skill_index_markdown: config.path("/skill.md"),
158            skill_index_json: config.path("/skill.json"),
159            agent_guide: config.path("/agent.md"),
160            openapi: config.path("/openapi.json"),
161            a2a_agent_card: config.path("/.well-known/agent.json"),
162            ai_plugin: config.path("/.well-known/ai-plugin.json"),
163            mcp: config.path("/.well-known/mcp"),
164            health: config.path("/heartbeat.json"),
165            skills_bundle: Some(config.path("/.well-known/skills.zip")),
166        },
167        skills: Some(
168            get_skill_references(config)
169                .into_iter()
170                .map(|s| SkillPointer {
171                    id: s.id,
172                    name: s.name,
173                    path: s.path,
174                })
175                .collect(),
176        ),
177    }
178}
179
180// ============================================================================
181// Text Content Generators
182// ============================================================================
183
184pub fn generate_ai_txt(config: &ContentConfig) -> String {
185    let base = &config.base_path;
186    format!(
187        r#"# AI Access Policy
188# Permissions for AI crawlers and agents.
189# See: https://ai-txt.org
190
191User-agent: *
192Allow: /
193
194AI-Discovery: {base}/.well-known/ai-discovery.json
195
196LLMs-Txt: {base}/llms.txt
197LLMs-Full: {base}/llms-full.txt
198OpenAPI: {base}/openapi.json
199Skills: {base}/skill.json
200Agent-Guide: {base}/agent.md
201
202# Authentication
203# Requests require a Bearer token or x-cedros-org-id header.
204# See: {base}/agent.md
205
206# Rate Limits
207# API endpoints: 100 req/min per key
208# Admin endpoints: 30 req/min per key
209"#,
210        base = base
211    )
212}
213
214pub fn generate_llms_txt(config: &ContentConfig) -> String {
215    let base = &config.base_path;
216    format!(
217        r#"# Cedros Data
218
219> Multi-site Postgres-backed content and custom data storage API. Supports JSONB and typed-table collections, custom schemas, contract verification, import/export, default page templates, media storage (S3), and metered reads.
220
221Cedros Data provides structured content storage with first-class support for multi-site architectures. Each site has isolated collections, schemas, and page templates.
222
223## Quick Start
224
2251. Register a site: `POST {base}/site` with `{{"display_name": "My Site"}}`
2262. Create a collection: `POST {base}/collections` with `{{"collection_name": "articles", "mode": "jsonb"}}`
2273. Upsert entries: `POST {base}/entries/upsert` with `{{"collection_name": "articles", "entry_key": "hello", "payload": {{}}}}`
2284. Query entries: `POST {base}/entries/query` with `{{"collection_name": "articles"}}`
229
230## Docs
231
232- [{base}/agent.md]({base}/agent.md): Agent integration guide
233- [{base}/llms-full.txt]({base}/llms-full.txt): Complete API documentation
234- [{base}/llms-admin.txt]({base}/llms-admin.txt): Admin operations reference
235
236## Skills
237
238- [{base}/skills/data.md]({base}/skills/data.md): Entry upsert, query, collections
239- [{base}/skills/admin.md]({base}/skills/admin.md): Site bootstrap, collection admin, pages
240- [{base}/skills/schema.md]({base}/skills/schema.md): Custom schemas, contracts, import/export
241- [{base}/skills/storage.md]({base}/skills/storage.md): Media upload, asset management
242- [{base}/skills/site.md]({base}/skills/site.md): Site registration, migration, config
243
244## API
245
246- [{base}/openapi.json]({base}/openapi.json): Full OpenAPI 3.0 specification
247- [{base}/skill.json]({base}/skill.json): Machine-readable skill metadata
248
249## Optional
250
251- [{base}/heartbeat.json]({base}/heartbeat.json): Health check endpoint
252- [{base}/.well-known/ai-discovery.json]({base}/.well-known/ai-discovery.json): Discovery index
253- [{base}/.well-known/agent.json]({base}/.well-known/agent.json): Google A2A Agent Card
254- [{base}/.well-known/mcp]({base}/.well-known/mcp): MCP server discovery
255"#,
256        base = base
257    )
258}
259
260pub fn generate_skill_md(config: &ContentConfig) -> String {
261    let base = &config.base_path;
262    let skills = get_skill_references(config);
263
264    let skills_yaml: Vec<String> = skills
265        .iter()
266        .map(|s| {
267            let mut yaml = format!(
268                "  - id: {}\n    path: {}\n    requiresAuth: {}",
269                s.id,
270                s.path,
271                s.requires_auth.unwrap_or(false)
272            );
273            if let Some(true) = s.requires_admin {
274                yaml.push_str("\n    requiresAdmin: true");
275            }
276            yaml
277        })
278        .collect();
279
280    let skills_table: Vec<String> = skills
281        .iter()
282        .map(|s| {
283            let auth = if s.requires_admin == Some(true) {
284                "Yes (Admin)"
285            } else if s.requires_auth == Some(true) {
286                "Yes"
287            } else {
288                "No"
289            };
290            format!(
291                "| [{}]({}) | {} | {} |",
292                s.name, s.path, s.description, auth
293            )
294        })
295        .collect();
296
297    format!(
298        r#"---
299name: cedros-data
300version: "{version}"
301description: Multi-site Postgres-backed content and custom data storage API
302category: data-storage
303apiBase: "{base}"
304capabilities:
305  multiSite: true
306  collections: true
307  customSchema: true
308  typedTables: true
309  contracts: true
310  importExport: true
311  defaultPages: true
312  mediaStorage: true
313  meteredReads: true
314authentication:
315  methods: [bearer-token, x-cedros-org-id]
316  recommended: bearer-token
317  header: "Authorization: Bearer <token>"
318rateLimits:
319  api: "100 req/min per key"
320  admin: "30 req/min per key"
321skills:
322{skills_yaml}
323---
324
325# Cedros Data Skills
326
327Multi-site Postgres-backed content and custom data storage API. Supports JSONB and typed-table collections, custom schemas, contract verification, import/export, default page templates, media storage (S3), and metered reads.
328
329## Available Skills
330
331| Skill | Description | Auth Required |
332|-------|-------------|---------------|
333{skills_table}
334
335## Quick Start
336
3371. Register a site: `POST {base}/site`
3382. Create a collection: `POST {base}/collections`
3393. Upsert entries: `POST {base}/entries/upsert`
3404. Query entries: `POST {base}/entries/query`
341
342## Discovery Endpoints
343
344| Endpoint | Format | Purpose |
345|----------|--------|---------|
346| {base}/llms.txt | text | Brief API summary |
347| {base}/llms-full.txt | text | Complete documentation |
348| {base}/llms-admin.txt | text | Admin operations |
349| {base}/skill.json | JSON | Machine-readable skill metadata |
350| {base}/agent.md | markdown | Integration guide |
351| {base}/openapi.json | JSON | Full OpenAPI specification |
352
353## Authentication
354
355All write operations require a Bearer token via the `Authorization` header. Multi-site operations use the `x-cedros-org-id` header for site scoping.
356
357## Error Format
358
359```json
360{{
361  "error": "Human-readable description"
362}}
363```
364"#,
365        version = config.version,
366        base = base,
367        skills_yaml = skills_yaml.join("\n"),
368        skills_table = skills_table.join("\n"),
369    )
370}
371
372// ============================================================================
373// Manifest Generators
374// ============================================================================
375
376pub fn get_ai_plugin_manifest(config: &ContentConfig) -> AiPluginManifest {
377    AiPluginManifest {
378        schema_version: "v1".to_string(),
379        name_for_human: "Cedros Data".to_string(),
380        name_for_model: "cedros_data".to_string(),
381        description_for_human: "Multi-site content and custom data storage API".to_string(),
382        description_for_model: format!(
383            "Cedros Data API for structured content storage. Supports JSONB and typed-table \
384            collections, custom schemas, contracts, import/export, and media storage. \
385            Create collections with POST {}/collections, upsert entries with POST {}/entries/upsert.",
386            config.base_path, config.base_path
387        ),
388        auth: AiPluginAuth {
389            auth_type: "bearer".to_string(),
390            instructions: Some(format!(
391                "Authenticate with a Bearer token. See {}/agent.md for details.",
392                config.base_path
393            )),
394        },
395        api: AiPluginApi {
396            api_type: "openapi".to_string(),
397            url: config.path("/openapi.json"),
398        },
399    }
400}
401
402pub fn get_agent_card(config: &ContentConfig) -> AgentCard {
403    let skills = get_skill_references(config);
404
405    AgentCard {
406        name: "Cedros Data".to_string(),
407        description: config.description.clone(),
408        url: config.base_path.clone(),
409        version: config.version.clone(),
410        capabilities: AgentCapabilities {
411            streaming: false,
412            push_notifications: false,
413            state_management: true,
414        },
415        authentication: AgentAuthentication {
416            schemes: vec![AuthScheme {
417                scheme: "bearer".to_string(),
418                description: "Bearer token authentication".to_string(),
419                instructions_url: Some(config.path("/agent.md")),
420            }],
421        },
422        skills: skills
423            .into_iter()
424            .map(|s| AgentSkill {
425                id: s.id.clone(),
426                name: s.name,
427                description: s.description,
428                input_modes: vec!["application/json".to_string()],
429                output_modes: vec!["application/json".to_string()],
430                documentation_url: Some(s.path),
431                openapi_tag: Some(s.id.to_uppercase()),
432            })
433            .collect(),
434        documentation_url: Some(config.path("/llms-full.txt")),
435        provider: Some(AgentProvider {
436            name: "Cedros".to_string(),
437            url: config.homepage.clone(),
438        }),
439    }
440}
441
442pub fn get_mcp_discovery(config: &ContentConfig) -> McpDiscovery {
443    McpDiscovery {
444        name: config.name.clone(),
445        version: config.version.clone(),
446        protocol_version: "2024-11-05".to_string(),
447        description: config.description.clone(),
448        capabilities: McpCapabilities {
449            tools: true,
450            resources: true,
451            prompts: false,
452            sampling: false,
453        },
454        tools: vec![
455            McpTool {
456                name: "upsert_entry".to_string(),
457                description: "Create or update a content entry in a collection".to_string(),
458                input_schema: serde_json::json!({
459                    "type": "object",
460                    "properties": {
461                        "collection_name": {
462                            "type": "string",
463                            "description": "Target collection name"
464                        },
465                        "entry_key": {
466                            "type": "string",
467                            "description": "Unique entry key within the collection"
468                        },
469                        "payload": {
470                            "type": "object",
471                            "description": "Entry payload (JSON object)"
472                        }
473                    },
474                    "required": ["collection_name", "entry_key", "payload"]
475                }),
476            },
477            McpTool {
478                name: "query_entries".to_string(),
479                description: "Query entries from a collection with optional filters".to_string(),
480                input_schema: serde_json::json!({
481                    "type": "object",
482                    "properties": {
483                        "collection_name": {
484                            "type": "string",
485                            "description": "Collection to query"
486                        },
487                        "entry_keys": {
488                            "type": "array",
489                            "items": { "type": "string" },
490                            "description": "Optional specific entry keys to fetch"
491                        }
492                    },
493                    "required": ["collection_name"]
494                }),
495            },
496            McpTool {
497                name: "register_collection".to_string(),
498                description: "Create a new collection for storing entries".to_string(),
499                input_schema: serde_json::json!({
500                    "type": "object",
501                    "properties": {
502                        "collection_name": {
503                            "type": "string",
504                            "description": "Name for the new collection"
505                        },
506                        "mode": {
507                            "type": "string",
508                            "enum": ["jsonb", "typed"],
509                            "description": "Storage mode (jsonb or typed table)"
510                        }
511                    },
512                    "required": ["collection_name", "mode"]
513                }),
514            },
515        ],
516        authentication: McpAuth {
517            required: true,
518            schemes: vec!["bearer".to_string()],
519            instructions: format!(
520                "Authenticate with a Bearer token. See {}/agent.md",
521                config.base_path
522            ),
523        },
524    }
525}