Skip to content

Commit 57a707c

Browse files
committed
Initial uplink implementation
1 parent d5950c9 commit 57a707c

26 files changed

+1960
-533
lines changed

Cargo.lock

+383-102
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[workspace]
22
resolver = "2"
3-
members = ["crates/mcp-apollo-server", "crates/rover-copy"]
3+
members = ["crates/mcp-apollo-server", "crates/mcp-apollo-registry"]
44

55
package.version = "0.1.0"
66

README.md

+29-9
Original file line numberDiff line numberDiff line change
@@ -135,24 +135,44 @@ You can now issue prompts related to weather forecasts and alerts, which will ca
135135

136136
#### Persisted Queries Manifests
137137

138-
The MCP server also supports reading operations from either an
139-
[Apollo](https://www.apollographql.com/docs/graphos/platform/security/persisted-queries#manifest-format)-
140-
or [Relay](https://relay.dev/docs/guides/persisted-queries/)-
141-
formatted persisted query manifest through the use of the `--manifest` and `--manifest-format` flags.
142-
An example of each is included in `graphql/weather/persisted_queries`.
138+
The MCP server also supports reading operations from an
139+
[Apollo](https://www.apollographql.com/docs/graphos/platform/security/persisted-queries#manifest-format) formatted
140+
persisted query manifest file through the use of the `--manifest` flag.
141+
142+
An example is included in `graphql/weather/persisted_queries`.
143143

144144
```sh
145-
# For apollo persisted query manifests
146145
target/debug/mcp-apollo-server \
147146
--directory <absolute path to this git repo> \
148147
-s graphql/weather/api.graphql \
149-
--manifest graphql/weather/persisted_queries/apollo.json --manifest-format apollo
148+
--header "apollographql-client-name:my-web-app" \
149+
--manifest graphql/weather/persisted_queries/apollo.json
150+
```
151+
152+
Note that when using persisted queries, if your queries are registered with a specific client name instead of `null`,
153+
you will need to configure the MCP server to send the necessary header indicating the client name to the router. This
154+
header is `apollographql-client-name` by default, but can be overridden in the router config by setting
155+
`telemetry.apollo.client_name_header`. Note that in the example persisted query manifest file, the client name
156+
is `my-web-app`.
157+
158+
This supports hot-reloading, so changes to the persisted query manifest file will be picked up by the MCP server
159+
without restarting.
150160

151-
# Or for relay persisted query manifests
161+
#### Uplink
162+
163+
The MCP server can also read persisted queries from Uplink using the `--uplink` option. This supports hot-reloading,
164+
so it will pick up changes from GraphOS automatically, without restarting the MCP server.
165+
166+
You must set the `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables to use Uplink. It is recommended to use
167+
a contract variant of your graph, with a PQ list associated with that variant. That way, you control exactly what
168+
persisted queries are available to the MCP server.
169+
170+
```sh
152171
target/debug/mcp-apollo-server \
153172
--directory <absolute path to this git repo> \
154173
-s graphql/weather/api.graphql \
155-
--manifest graphql/weather/persisted_queries/relay.json --manifest-format relay
174+
--header "apollographql-client-name:my-web-app" \
175+
--uplink
156176
```
157177

158178
# Running Your Own Graph

crates/mcp-apollo-registry/Cargo.toml

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[package]
2+
name = "mcp-apollo-registry"
3+
version = "0.1.0"
4+
edition = "2024"
5+
authors = ["Apollo <[email protected]>"]
6+
license = "MIT OR Apache-2.0"
7+
repository = "https://github.com/apollographql/mcp-apollo"
8+
description = "Registry providing schema and operations to the MCP Server"
9+
10+
[dependencies]
11+
futures = "0.3"
12+
graphql_client = "0.14.0"
13+
notify = "8.0.0"
14+
parking_lot = "0.12"
15+
reqwest = { version = "0.12.15", features = ["json", "gzip"] }
16+
secrecy = "0.10.3"
17+
serde = { version = "1.0", features = ["derive"] }
18+
serde_json = "1.0"
19+
thiserror = "2.0.12"
20+
tokio = { version = "1.0", features = ["fs", "sync", "time", "rt-multi-thread", "macros"] }
21+
tokio-stream = "0.1"
22+
tower = "0.5.2"
23+
tracing = "0.1"
24+
url = "2.4"
25+
26+
[lints]
27+
workspace = true
+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
use std::path::{Path, PathBuf};
2+
use std::time::Duration;
3+
4+
use futures::prelude::*;
5+
use notify::Config;
6+
use notify::EventKind;
7+
use notify::PollWatcher;
8+
use notify::RecursiveMode;
9+
use notify::Watcher;
10+
use notify::event::DataChange;
11+
use notify::event::MetadataKind;
12+
use notify::event::ModifyKind;
13+
use tokio::sync::mpsc;
14+
use tokio::sync::mpsc::error::TrySendError;
15+
16+
#[cfg(not(test))]
17+
const DEFAULT_WATCH_DURATION: Duration = Duration::from_secs(3);
18+
19+
#[cfg(test)]
20+
const DEFAULT_WATCH_DURATION: Duration = Duration::from_millis(100);
21+
22+
/// Creates a stream events whenever the file at the path has changes. The stream never terminates
23+
/// and must be dropped to finish watching.
24+
///
25+
/// # Arguments
26+
///
27+
/// * `path`: The file to watch
28+
///
29+
/// returns: impl Stream<Item=()>
30+
///
31+
pub(crate) fn watch(path: &Path) -> impl Stream<Item = ()> + use<> {
32+
watch_with_duration(path, DEFAULT_WATCH_DURATION)
33+
}
34+
35+
#[allow(clippy::panic)] // TODO: code copied from router contained existing panics
36+
fn watch_with_duration(path: &Path, duration: Duration) -> impl Stream<Item = ()> + use<> {
37+
// Due to the vagaries of file watching across multiple platforms, instead of watching the
38+
// supplied path (file), we are going to watch the parent (directory) of the path.
39+
let config_file_path = PathBuf::from(path);
40+
let watched_path = config_file_path.clone();
41+
42+
let (watch_sender, watch_receiver) = mpsc::channel(1);
43+
let watch_receiver_stream = tokio_stream::wrappers::ReceiverStream::new(watch_receiver);
44+
// We can't use the recommended watcher, because there's just too much variation across
45+
// platforms and file systems. We use the Poll Watcher, which is implemented consistently
46+
// across all platforms. Less reactive than other mechanisms, but at least it's predictable
47+
// across all environments. We compare contents as well, which reduces false positives with
48+
// some additional processing burden.
49+
let config = Config::default()
50+
.with_poll_interval(duration)
51+
.with_compare_contents(true);
52+
let mut watcher = PollWatcher::new(
53+
move |res: Result<notify::Event, notify::Error>| match res {
54+
Ok(event) => {
55+
// The two kinds of events of interest to use are writes to the metadata of a
56+
// watched file and changes to the data of a watched file
57+
if matches!(
58+
event.kind,
59+
EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime))
60+
| EventKind::Modify(ModifyKind::Data(DataChange::Any))
61+
) && event.paths.contains(&watched_path)
62+
{
63+
loop {
64+
match watch_sender.try_send(()) {
65+
Ok(_) => break,
66+
Err(err) => {
67+
tracing::warn!(
68+
"could not process file watch notification. {}",
69+
err.to_string()
70+
);
71+
if matches!(err, TrySendError::Full(_)) {
72+
std::thread::sleep(Duration::from_millis(50));
73+
} else {
74+
panic!("event channel failed: {err}");
75+
}
76+
}
77+
}
78+
}
79+
}
80+
}
81+
Err(e) => tracing::error!("event error: {:?}", e),
82+
},
83+
config,
84+
)
85+
.unwrap_or_else(|_| panic!("could not create watch on: {config_file_path:?}"));
86+
watcher
87+
.watch(&config_file_path, RecursiveMode::NonRecursive)
88+
.unwrap_or_else(|_| panic!("could not watch: {config_file_path:?}"));
89+
// Tell watchers once they should read the file once,
90+
// then listen to fs events.
91+
stream::once(future::ready(()))
92+
.chain(watch_receiver_stream)
93+
.chain(stream::once(async move {
94+
// This exists to give the stream ownership of the hotwatcher.
95+
// Without it hotwatch will get dropped and the stream will terminate.
96+
// This code never actually gets run.
97+
// The ideal would be that hotwatch implements a stream and
98+
// therefore we don't need this hackery.
99+
drop(watcher);
100+
}))
101+
.boxed()
102+
}

crates/mcp-apollo-registry/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub(crate) mod files;
2+
pub mod uplink;

0 commit comments

Comments
 (0)