Skip to content
Merged
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
604 changes: 604 additions & 0 deletions crates/outer/src/examples/apply.rs

Large diffs are not rendered by default.

416 changes: 416 additions & 0 deletions crates/outer/src/examples/apply/tests.rs

Large diffs are not rendered by default.

184 changes: 170 additions & 14 deletions crates/outer/src/examples.rs → crates/outer/src/examples/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
//! Copy-paste patterns served over the public `examples` MCP tool.
//! Copy-paste patterns served over the public `examples` MCP tool, and
//! applied by `add_example` / `create_app {examples}`.
//!
//! The patterns live in `examples/` in the `outer-templates` repo, vendored
//! into the store as a git bundle at the pinned sha (see [`crate::templates`]).
//! [`list`] and [`get`] extract only `examples/` into a cache dir under
//! Outer's data dir, once per sha, and serve from there. A pinned sha with no
//! `examples/` yields an empty list. A bad example is skipped with a warn and
//! never fails the whole list.
pub mod apply;

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
Expand All @@ -30,7 +32,6 @@ pub struct Summary {
pub title: String,
pub summary: String,
pub template: String,
pub dependencies: BTreeMap<String, String>,
pub files: Vec<FileRef>,
}

Expand All @@ -41,14 +42,25 @@ pub struct ExampleFile {
pub text: String,
}

/// One full example: the README plus every file's text.
/// One full example: the README plus every file's text, plus the apply
/// metadata `add_example` plans from (`requires`, `nav`, `bindings`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavEntry {
pub href: String,
pub label: String,
pub icon: String,
}

/// One full example: the README, every file's text, and the apply metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Example {
pub name: String,
pub title: String,
pub summary: String,
pub template: String,
pub dependencies: BTreeMap<String, String>,
pub requires: Vec<String>,
pub nav: Vec<NavEntry>,
pub bindings: BTreeMap<String, Vec<BTreeMap<String, String>>>,
pub readme: String,
pub files: Vec<ExampleFile>,
}
Expand All @@ -68,15 +80,29 @@ pub fn is_valid_name(name: &str) -> bool {
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
}

/// The `example.json` contract: name, title, summary, template, plus optional
/// dependencies. Unknown fields are ignored.
/// The `example.json` contract: title, summary, template, plus optional
/// `requires`, `nav` and `bindings`. `dependencies` is not supported and
/// rejects the example; unknown fields are ignored.
#[derive(Debug, Deserialize)]
struct NavJson {
href: String,
label: String,
icon: String,
}

#[derive(Debug, Deserialize)]
struct ExampleJson {
title: String,
summary: String,
template: String,
#[serde(default)]
dependencies: BTreeMap<String, String>,
requires: Vec<String>,
#[serde(default)]
nav: Vec<NavJson>,
#[serde(default)]
bindings: BTreeMap<String, Vec<BTreeMap<String, String>>>,
#[serde(default)]
dependencies: Option<serde_json::Value>,
}

/// Largest single file an example may carry (contract: 64 KiB).
Expand Down Expand Up @@ -134,7 +160,6 @@ impl Example {
title: self.title.clone(),
summary: self.summary.clone(),
template: self.template.clone(),
dependencies: self.dependencies.clone(),
files: self
.files
.iter()
Expand All @@ -145,6 +170,30 @@ impl Example {
.collect(),
}
}

/// The apply engine's view of this example.
pub fn apply_input(&self) -> apply::ExampleInput {
apply::ExampleInput {
name: self.name.clone(),
template: self.template.clone(),
requires: self.requires.clone(),
nav: self
.nav
.iter()
.map(|item| apply::NavItem {
href: item.href.clone(),
label: item.label.clone(),
icon: item.icon.clone(),
})
.collect(),
bindings: self.bindings.clone(),
files: self
.files
.iter()
.map(|file| (file.path.clone(), file.text.clone()))
.collect(),
}
}
}

/// Extract `examples/` for the current [`crate::templates::TemplateRef`]
Expand Down Expand Up @@ -350,6 +399,38 @@ fn load_example(dir: &Path, name: &str) -> Option<Example> {
if crate::templates::Template::from_id(&meta.template).is_none() {
return reject(&format!("bad template {:?}", meta.template));
}
if meta.dependencies.is_some() {
return reject("example.json \"dependencies\" is not supported (examples ship files only; add_example never touches package.json)");
}
for dep in &meta.requires {
if !is_valid_name(dep) {
return reject(&format!("bad requires entry {dep:?}"));
}
}
let mut nav = Vec::with_capacity(meta.nav.len());
for item in meta.nav {
if item.href.is_empty() || !item.href.starts_with('/') {
return reject(&format!("bad nav href {:?}", item.href));
}
if item.label.is_empty() {
return reject(&format!("bad nav label for {:?}", item.href));
}
if !apply::is_valid_icon(&item.icon) {
return reject(&format!("bad nav icon {:?}", item.icon));
}
nav.push(NavEntry {
href: item.href,
label: item.label,
icon: item.icon,
});
}
for key in meta.bindings.keys() {
if key != "d1_databases" && key != "r2_buckets" {
return reject(&format!(
"bad bindings key {key:?}: want d1_databases or r2_buckets"
));
}
}
let readme = match std::fs::read_to_string(dir.join("README.md")) {
Ok(readme) => readme,
Err(_) => return reject("missing README.md"),
Expand Down Expand Up @@ -381,7 +462,9 @@ fn load_example(dir: &Path, name: &str) -> Option<Example> {
title: meta.title,
summary: meta.summary,
template: meta.template,
dependencies: meta.dependencies,
requires: meta.requires,
nav,
bindings: meta.bindings,
readme,
files: out,
})
Expand Down Expand Up @@ -496,7 +579,6 @@ mod tests {
"title": title,
"summary": summary,
"template": template,
"dependencies": {"left-pad": "^1.3.0"},
})
.to_string()
}
Expand Down Expand Up @@ -561,10 +643,6 @@ mod tests {
assert_eq!(all[1].title, "List form");
assert_eq!(all[1].summary, "A D1-backed list with add and complete.");
assert_eq!(all[1].template, "app");
assert_eq!(
all[1].dependencies.get("left-pad").map(String::as_str),
Some("^1.3.0")
);
let paths: Vec<_> = all[1].files.iter().map(|file| file.path.as_str()).collect();
assert_eq!(paths, ["pages/list.server.ts", "pages/list.tsx"]);
assert!(all[1].files.iter().all(|file| file.lines > 0));
Expand All @@ -588,6 +666,36 @@ mod tests {
);
}

#[tokio::test]
async fn an_example_carrying_dependencies_is_skipped() {
let store = Store::memory();
let json = serde_json::json!({
"name": "need",
"title": "Need",
"summary": "Wants a package.",
"template": "app",
"dependencies": {"some-pkg": "^1.0.0"},
})
.to_string();
fixture(
&store,
&[(
"need",
&[
("example.json", json.as_str()),
("README.md", "# Need\n"),
("pages/need.ts", "export const need = 1;\n"),
],
)],
&[],
)
.await;
let state = tool_state(store).await;
let dir = &state.delete_cfg.data_dir;
assert!(list(&state.store, dir, None).await.unwrap().is_empty());
assert!(get(&state.store, dir, "need").await.unwrap().is_none());
}

#[tokio::test]
async fn get_returns_the_readme_and_files() {
let state = two_example_state().await;
Expand Down Expand Up @@ -621,6 +729,7 @@ mod tests {
ExamplesInput {
name: Some("nope".to_string()),
template: None,
files: None,
},
&ctx,
)
Expand Down Expand Up @@ -654,6 +763,7 @@ mod tests {
ExamplesInput {
name: Some(bad.clone()),
template: None,
files: None,
},
&ctx,
)
Expand All @@ -666,6 +776,7 @@ mod tests {
ExamplesInput {
name: Some("list-form".to_string()),
template: None,
files: None,
},
&ctx,
)
Expand Down Expand Up @@ -736,6 +847,7 @@ mod tests {
ExamplesInput {
name: Some("big".to_string()),
template: None,
files: Some(true),
},
&ctx,
)
Expand All @@ -751,6 +863,50 @@ mod tests {
"{text}"
);
assert!(text.starts_with("# Big\n"), "{text}");
// Contents travel in the text block only, never duplicated.
assert!(
!text.contains("\"text\""),
"no JSON envelope in the text block"
);
for entry in structured["files"].as_array().unwrap() {
assert!(entry.get("text").is_none(), "no contents in structured");
}
}

#[tokio::test]
async fn without_files_lists_without_contents() {
let state = two_example_state().await;
let ctx = Ctx {
state: &state,
principal: None,
client_ip: None,
};
let value = examples_tool(
ExamplesInput {
name: Some("list-form".to_string()),
template: None,
files: None,
},
&ctx,
)
.await
.unwrap();
let text = value["content"][0]["text"].as_str().unwrap();
assert!(text.len() < 6 * 1024, "stays small: {}", text.len());
assert!(text.contains("pages/list.tsx"), "{text}");
assert!(
!text.contains("export default function List"),
"no file contents: {text}"
);
assert!(text.contains("add_example"), "{text}");
let structured = &value["structuredContent"];
assert_eq!(structured["name"], "list-form");
assert_eq!(structured["requires"].as_array().unwrap().len(), 0);
assert_eq!(structured["files"].as_array().unwrap().len(), 2);
assert!(structured.get("readme").is_some());
for entry in structured["files"].as_array().unwrap() {
assert!(entry.get("text").is_none(), "no contents in structured");
}
}

#[tokio::test]
Expand Down
2 changes: 1 addition & 1 deletion crates/outer/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub const LATEST_PROTOCOL: &str = "2025-11-25";

/// Exactly the §8.1 text (Codex reads only this much). The length test pins
/// it at or under 512 chars.
pub const INSTRUCTIONS: &str = "New apps are empty branded shells: build the request, nothing to delete. Start `open_app`, read (`read_files` batches, `search_code`), copy patterns from `examples`, iterate `stage`→`check`→`ship` (`commit` ships at once). Relay `user_message` verbatim. Guides: `docs {topic}`, /llms.txt.";
pub const INSTRUCTIONS: &str = "Start with `create_app {examples}` or `add_example`, then customise with `commit`. New apps are empty shells until you build. Begin `open_app`, read (`read_files` batches, `search_code`), iterate `stage`→`check`→`ship`. Relay `user_message` verbatim. Guides: `docs {topic}`, /llms.txt.";

/// Largest `POST /mcp` body: one `commit` at the caps fits (spec §8.1).
pub const MAX_BODY_BYTES: usize = 32 * 1024 * 1024;
Expand Down
23 changes: 14 additions & 9 deletions crates/outer/src/mcp/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
use serde_json::Value;

use crate::mcp::tools::{
account, apps as app_inputs, backups as backup_inputs, code as code_inputs,
collaborators as collab_inputs, data as data_inputs, domains as domain_inputs,
drafts as draft_inputs, examples as examples_inputs, export as export_inputs,
look as look_inputs, open_app as open_app_inputs, preview as preview_inputs,
runtime as runtime_inputs, secrets as secrets_inputs, NoArgs,
account, add_example as add_example_inputs, apps as app_inputs, backups as backup_inputs,
code as code_inputs, collaborators as collab_inputs, data as data_inputs,
domains as domain_inputs, drafts as draft_inputs, examples as examples_inputs,
export as export_inputs, look as look_inputs, open_app as open_app_inputs,
preview as preview_inputs, runtime as runtime_inputs, secrets as secrets_inputs, NoArgs,
};

/// Who may call the tool. `Public` tools run without a token; anything else
Expand Down Expand Up @@ -97,7 +97,7 @@ pub fn hints_for(name: &str) -> Option<Hints> {
"verify_code" | "create_api_key" | "create_app" | "commit" | "rollback" | "rename_app"
| "set_secret" | "resolve_error" | "ignore_error" | "reopen_error" | "add_domain"
| "backup_database" | "promote_preview" | "upgrade" | "manage_billing" | "stage"
| "check" | "ship" => Hints::WRITE,
| "check" | "ship" | "add_example" => Hints::WRITE,
// Emails an invite to an address outside Outer.
"add_collaborator" => Hints {
open_world: true,
Expand Down Expand Up @@ -233,7 +233,7 @@ pub fn registry() -> Vec<ToolDef> {
),
tool::<app_inputs::CreateAppInput>(
"create_app",
"Create an app and deploy it: {slug, name?, description?, template?, wait?}. Name (1-60 chars) and description (1-160 chars) brand the app's brand.json; both default from the slug. Template \"app\" is an interactive Ilha + Void app, \"website\" an Astro content site (default \"app\"). Waits for the first deploy by default.",
"Create an app and deploy it: {slug, name?, description?, template?, examples?, wait?}. Examples like [\"passkeys\"] seed working features in the first commit. Name (1-60 chars) and description (1-160 chars) brand the app; both default from the slug. Template \"app\" is an interactive Ilha + Void app, \"website\" an Astro content site (default \"app\"). Waits for the first deploy by default.",
Account, false,
),
tool::<NoArgs>(
Expand Down Expand Up @@ -463,9 +463,14 @@ pub fn registry() -> Vec<ToolDef> {
),
tool::<examples_inputs::ExamplesInput>(
"examples",
"Copy-paste patterns for apps and sites: `examples` lists them, `examples {name}` returns the README and files to copy.",
"Ready features for apps and sites: `examples` lists them, `examples {name}` shows the README and file list (contents with `files: true`). Apply one with `add_example`.",
Public, false,
),
tool::<add_example_inputs::AddExampleInput>(
"add_example",
"Add a ready feature to your app: {app, name} (+ optional wait). Commits the example's pages, nav, bindings and migrations in one change and deploys. Customise with commit after.",
Account, false,
),
]
}

Expand All @@ -492,7 +497,7 @@ mod tests {
#[test]
fn registry_shapes() {
let tools = registry();
assert_eq!(tools.len(), 60);
assert_eq!(tools.len(), 61);
}
}

Expand Down
Loading
Loading