Skip to content

Commit a540f46

Browse files
Apps: Allow resource to be separately set for MCP/AppsSDK (#627)
* Apps: Allow resource to be separately set for MCP/AppsSDK * Add/fix tests * Borrow path instead of cloning
1 parent 15c11a2 commit a540f46

File tree

5 files changed

+420
-80
lines changed

5 files changed

+420
-80
lines changed

crates/apollo-mcp-server/src/apps/app.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ pub(crate) struct App {
2828

2929
#[derive(Clone, Debug)]
3030
pub(crate) enum AppResource {
31+
Targeted(TargetedAppResource),
32+
Single(AppResourceSource),
33+
}
34+
35+
#[derive(Clone, Debug)]
36+
pub(crate) struct TargetedAppResource {
37+
pub(crate) openai: Option<AppResourceSource>,
38+
pub(crate) mcp: Option<AppResourceSource>,
39+
}
40+
41+
#[derive(Clone, Debug)]
42+
pub(crate) enum AppResourceSource {
3143
Local(String),
3244
Remote(Url),
3345
}

crates/apollo-mcp-server/src/apps/manifest.rs

Lines changed: 167 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ use serde_json::{Map, Value, json};
1010
use tracing::debug;
1111
use url::Url;
1212

13-
use crate::apps::app::{App, AppResource, AppTool, PrefetchOperation};
13+
use crate::apps::app::{
14+
App, AppResource, AppResourceSource, AppTool, PrefetchOperation, TargetedAppResource,
15+
};
1416
use crate::{
1517
custom_scalar_map::CustomScalarMap,
1618
operations::{MutationMode, Operation, RawOperation},
@@ -148,22 +150,21 @@ pub(crate) fn load_from_path(
148150
}
149151
}
150152

151-
let resource = if manifest.resource.starts_with("http://")
152-
|| manifest.resource.starts_with("https://")
153-
{
154-
let url = Url::parse(&manifest.resource).map_err(|err| {
155-
format!("Failed to parse resource URL {}: {err}", manifest.resource)
156-
})?;
157-
AppResource::Remote(url)
158-
} else {
159-
let resource_path = path.join(&manifest.resource);
160-
let contents = read_to_string(&resource_path).map_err(|err| {
161-
format!(
162-
"Failed to read resource from {resource_path}: {err}",
163-
resource_path = resource_path.to_string_lossy(),
164-
)
165-
})?;
166-
AppResource::Local(contents)
153+
// We may have a resource with just a single string OR an object specifying mcp + openai resources. Any of these could be file path or url.
154+
let resource = match manifest.resource {
155+
ManifestResource::Targeted(targets) => AppResource::Targeted(TargetedAppResource {
156+
mcp: targets
157+
.mcp
158+
.map(|resource| resource_source_from_string(resource, &path))
159+
.transpose()?,
160+
openai: targets
161+
.openai
162+
.map(|resource| resource_source_from_string(resource, &path))
163+
.transpose()?,
164+
}),
165+
ManifestResource::Single(resource) => {
166+
AppResource::Single(resource_source_from_string(resource, &path)?)
167+
}
167168
};
168169

169170
apps.push(App {
@@ -180,6 +181,23 @@ pub(crate) fn load_from_path(
180181
Ok(apps)
181182
}
182183

184+
fn resource_source_from_string(resource: String, path: &Path) -> Result<AppResourceSource, String> {
185+
if resource.starts_with("http://") || resource.starts_with("https://") {
186+
let url = Url::parse(&resource)
187+
.map_err(|err| format!("Failed to parse resource URL {}: {err}", resource))?;
188+
Ok(AppResourceSource::Remote(url))
189+
} else {
190+
let resource_path = path.join(&resource);
191+
let contents = read_to_string(&resource_path).map_err(|err| {
192+
format!(
193+
"Failed to read resource from {resource_path}: {err}",
194+
resource_path = resource_path.to_string_lossy(),
195+
)
196+
})?;
197+
Ok(AppResourceSource::Local(contents))
198+
}
199+
}
200+
183201
fn merge_inputs(
184202
orig: &mut Map<String, Value>,
185203
extra: Vec<ExtraInputDefinition>,
@@ -228,7 +246,7 @@ fn merge_inputs(
228246
struct Manifest {
229247
hash: String,
230248
operations: Vec<OperationDefinition>,
231-
resource: String,
249+
resource: ManifestResource,
232250
name: Option<String>,
233251
description: Option<String>,
234252
csp: Option<CSPSettings>,
@@ -241,6 +259,19 @@ struct Manifest {
241259
version: ManifestVersion,
242260
}
243261

262+
#[derive(Clone, Deserialize)]
263+
#[serde(untagged)]
264+
enum ManifestResource {
265+
Targeted(TargetedManifestResource),
266+
Single(String),
267+
}
268+
269+
#[derive(Clone, Deserialize)]
270+
pub(crate) struct TargetedManifestResource {
271+
openai: Option<String>,
272+
mcp: Option<String>,
273+
}
274+
244275
#[derive(Clone, Copy, Deserialize)]
245276
#[serde(rename_all = "kebab-case")]
246277
enum ManifestFormat {
@@ -341,6 +372,7 @@ pub(crate) struct CSPSettings {
341372
#[cfg(test)]
342373
mod test_load_from_path {
343374
use super::*;
375+
use crate::apps::app::AppResourceSource;
344376
use assert_fs::{TempDir, prelude::*};
345377

346378
#[test]
@@ -375,8 +407,10 @@ mod test_load_from_path {
375407
assert_eq!(apps.len(), 1);
376408
let app = &apps[0];
377409
match &app.resource {
378-
AppResource::Local(contents) => assert_eq!(contents, html),
379-
AppResource::Remote(url) => panic!("unexpected remote resource {url}"),
410+
AppResource::Single(AppResourceSource::Local(contents)) => {
411+
assert_eq!(contents, html)
412+
}
413+
other => panic!("unexpected resource {other:?}"),
380414
}
381415
assert_eq!(app.uri, "ui://widget/MyApp#abcdef".parse().unwrap());
382416
}
@@ -411,11 +445,11 @@ mod test_load_from_path {
411445
assert_eq!(apps.len(), 1);
412446
let app = &apps[0];
413447
match &app.resource {
414-
AppResource::Remote(url) => {
448+
AppResource::Single(AppResourceSource::Remote(url)) => {
415449
assert_eq!(url.as_str(), "https://example.com/widget/index.html")
416450
}
417-
AppResource::Local(contents) => {
418-
panic!("expected remote resource, found local: {contents}")
451+
other => {
452+
panic!("expected remote resource, found: {other:?}")
419453
}
420454
}
421455
}
@@ -929,4 +963,114 @@ mod test_load_from_path {
929963
Some("Cart filled!".to_string())
930964
);
931965
}
966+
967+
#[test]
968+
fn should_load_local_files_when_resource_is_targeted() {
969+
let temp = TempDir::new().expect("Could not create temporary directory for test");
970+
let app_dir = temp.child("TargetedApp");
971+
app_dir
972+
.child(MANIFEST_FILE_NAME)
973+
.write_str(
974+
r#"{"format": "apollo-ai-app-manifest",
975+
"version": "1",
976+
"hash": "abcdef",
977+
"resource": {
978+
"openai": "openai.html",
979+
"mcp": "mcp.html"
980+
},
981+
"operations": []}"#,
982+
)
983+
.unwrap();
984+
let openai_html = "<html>openai</html>";
985+
let mcp_html = "<html>mcp</html>";
986+
app_dir.child("openai.html").write_str(openai_html).unwrap();
987+
app_dir.child("mcp.html").write_str(mcp_html).unwrap();
988+
let apps = load_from_path(
989+
temp.path(),
990+
&Schema::parse("type Query { hello: String }", "schema.graphql")
991+
.unwrap()
992+
.validate()
993+
.unwrap(),
994+
None,
995+
MutationMode::All,
996+
false,
997+
false,
998+
true,
999+
)
1000+
.expect("Failed to load apps");
1001+
assert_eq!(apps.len(), 1);
1002+
let app = &apps[0];
1003+
match &app.resource {
1004+
AppResource::Targeted(targeted) => {
1005+
match targeted
1006+
.openai
1007+
.as_ref()
1008+
.expect("openai resource should exist")
1009+
{
1010+
AppResourceSource::Local(contents) => assert_eq!(contents, openai_html),
1011+
other => panic!("expected local openai resource, found: {other:?}"),
1012+
}
1013+
match targeted.mcp.as_ref().expect("mcp resource should exist") {
1014+
AppResourceSource::Local(contents) => assert_eq!(contents, mcp_html),
1015+
other => panic!("expected local mcp resource, found: {other:?}"),
1016+
}
1017+
}
1018+
other => panic!("expected targeted resource, found: {other:?}"),
1019+
}
1020+
}
1021+
1022+
#[test]
1023+
fn should_load_remote_urls_when_resource_is_targeted() {
1024+
let temp = TempDir::new().expect("Could not create temporary directory for test");
1025+
let app_dir = temp.child("TargetedRemoteApp");
1026+
app_dir
1027+
.child(MANIFEST_FILE_NAME)
1028+
.write_str(
1029+
r#"{"format": "apollo-ai-app-manifest",
1030+
"version": "1",
1031+
"hash": "abcdef",
1032+
"resource": {
1033+
"openai": "https://example.com/openai.html",
1034+
"mcp": "https://example.com/mcp.html"
1035+
},
1036+
"operations": []}"#,
1037+
)
1038+
.unwrap();
1039+
let apps = load_from_path(
1040+
temp.path(),
1041+
&Schema::parse("type Query { hello: String }", "schema.graphql")
1042+
.unwrap()
1043+
.validate()
1044+
.unwrap(),
1045+
None,
1046+
MutationMode::All,
1047+
false,
1048+
false,
1049+
true,
1050+
)
1051+
.expect("Failed to load apps");
1052+
assert_eq!(apps.len(), 1);
1053+
let app = &apps[0];
1054+
match &app.resource {
1055+
AppResource::Targeted(targeted) => {
1056+
match targeted
1057+
.openai
1058+
.as_ref()
1059+
.expect("openai resource should exist")
1060+
{
1061+
AppResourceSource::Remote(url) => {
1062+
assert_eq!(url.as_str(), "https://example.com/openai.html")
1063+
}
1064+
other => panic!("expected remote openai resource, found: {other:?}"),
1065+
}
1066+
match targeted.mcp.as_ref().expect("mcp resource should exist") {
1067+
AppResourceSource::Remote(url) => {
1068+
assert_eq!(url.as_str(), "https://example.com/mcp.html")
1069+
}
1070+
other => panic!("expected remote mcp resource, found: {other:?}"),
1071+
}
1072+
}
1073+
other => panic!("expected targeted resource, found: {other:?}"),
1074+
}
1075+
}
9321076
}

0 commit comments

Comments
 (0)