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
69 changes: 60 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 12 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,18 @@ exclude = [
]

[workspace]
members = [".", "crates/imcp2-core"]
members = [".", "crates/imcp2-core", "crates/imcp2-local"]

# Single source of truth for the version pins both crates share, so the agent,
# codec, and candid lines can never drift between imcp2-core and imcp2.
# Single source of truth for the version pins the crates share, so the agent,
# codec, and candid lines can never drift between imcp2-core, imcp2, and
# imcp2-local.
[workspace.dependencies]
imcp2-core = { path = "crates/imcp2-core", version = "0.2.0" }
rmcp = "1.7"
axum = "0.8"
anyhow = "1"
uuid = { version = "1", features = ["v4"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# The Agent is INJECTED by the embedding application (see McpServer::new), so a
# host shares one boundary-node client and one ic-agent across the process.
# Deliberately a direct dependency for now: sourcing ic-agent through ic-bn-lib
Expand Down Expand Up @@ -83,14 +88,14 @@ rmcp = { workspace = true, features = [
ic-agent = { workspace = true }
candid = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "time"] }
axum = "0.8"
anyhow = "1"
axum = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { workspace = true }
tokio-util = "0.7"
uuid = { version = "1", features = ["v4"] }
uuid = { workspace = true }
tower-http = { version = "0.7", features = ["cors"] }
# Metrics exposition at /metrics. Pinned to the version dfinity/ic uses: these
# series are destined for the same Prometheus / Victoria Metrics clusters that
Expand Down
3 changes: 0 additions & 3 deletions crates/imcp2-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,3 @@ reqwest = { workspace = true }
getrandom = { workspace = true }
urlencoding = { workspace = true }
url = { workspace = true }
# The request-extension type the Bearer session lookup reads (axum re-exports
# this same type; depending on `http` directly keeps axum out of this crate).
http = { workspace = true }
4 changes: 4 additions & 0 deletions crates/imcp2-core/src/assets/connect.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ body {
/* Terminal error: the spinner tile is dropped and the message carries the state.
The pinned page's script adds `.error` to `.screen`; the error page ships it. */
.screen.error .spinner-tile { display: none; }
/* Terminal success ("Signed in — you can close this tab", the local redeem's
`done` answer): the spinner is likewise dropped, but the contact line stays
hidden — nothing went wrong. */
.screen.done .spinner-tile { display: none; }
@keyframes connect-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
@media (prefers-color-scheme: dark) {
Expand Down
54 changes: 42 additions & 12 deletions crates/imcp2-core/src/iiconnect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,14 @@ const PINNED_PAGE_HTML: &str = include_str!("assets/connect-callback.html");
/// one dynamic value, the redeem URL, is spliced in by replacing
/// `__REDEEM_URL__`, which sits inside a quoted JS string literal below.
const PINNED_PAGE_JS: &str = r#"(function () {
function show(t, err) {
document.getElementById('m').textContent = t;
if (err) {
var c = document.querySelector('.screen');
if (c) { c.classList.add('error'); }
// Swap the status line to `message` and move the screen into a terminal
// state: 'error' drops the spinner and reveals the contact line, 'done'
// just drops the spinner (see connect.css).
function show(message, stateClass) {
document.getElementById('m').textContent = message;
if (stateClass) {
var screenElement = document.querySelector('.screen');
if (screenElement) { screenElement.classList.add(stateClass); }
}
}
// II delivers #delegation=<chain JSON>&state=<state>: the two-hop chain plus
Expand All @@ -120,23 +123,28 @@ const PINNED_PAGE_JS: &str = r#"(function () {
// Scrub the delegation from the address bar, keeping the path and any query
// string the declared callback carries. Best-effort: the POST below works
// even if a browser refuses the history call.
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
try { history.replaceState(null, '', location.pathname + location.search); } catch (ignored) {}
fetch("__REDEEM_URL__", {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: body
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d && d.redirect) {
location.replace(d.redirect);
.then(function (response) { return response.json().catch(function () { return {}; }); })
.then(function (data) {
// Two success shapes, per deployment: the hosted redeem answers with the
// `redirect` that continues its OAuth flow; a local redeem has nothing to
// navigate to, so it answers `done` and this page IS the terminal state.
if (data && data.redirect) {
location.replace(data.redirect);
} else if (data && data.done) {
show("Signed in — you can close this tab.", 'done');
} else {
show((d && d.error) || "We couldn't finish the connection. Restart from your client.", true);
show((data && data.error) || "We couldn't finish the connection. Restart from your client.", 'error');
}
})
.catch(function () {
show("We couldn't reach the server. Restart from your client.", true);
show("We couldn't reach the server. Restart from your client.", 'error');
});
})();"#;

Expand Down Expand Up @@ -488,6 +496,28 @@ mod tests {
assert!(url.contains("&state=sess-1&ttl=3600&registration_key=AQID"), "{url}");
}

// The page's script resolves the redeem answer three ways: `redirect`
// (hosted — navigate into the OAuth continuation), `done` (local — render
// the terminal "Signed in" state in place; there is nothing to navigate
// to), and everything else as the error screen. The `done` state drops the
// spinner without revealing the contact line (see connect.css). Guards the
// contract the local redeem answers with `{"done": true}` against.
#[test]
fn pinned_page_script_handles_redirect_done_and_error() {
let page = super::pinned_callback_page("/redeem", "mcp@dfinity.org");
assert!(page.html.contains("data.redirect"), "hosted success arm");
assert!(page.html.contains("data.done"), "local success arm");
assert!(
page.html.contains("Signed in \u{2014} you can close this tab."),
"the done arm renders the terminal signed-in state"
);
assert!(page.html.contains("'done'") && page.html.contains("'error'"));
assert!(
super::CONNECT_PAGE_CSS.contains(".screen.done .spinner-tile"),
"the done state must drop the spinner (connect.css)"
);
}

#[test]
fn pinned_page_binds_one_nonce_into_html_and_csp() {
let page = super::pinned_callback_page("/oauth/connect/redeem", "mcp@dfinity.org");
Expand Down
13 changes: 7 additions & 6 deletions crates/imcp2-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
//! plain values so each binary wraps them in its own HTTP handlers.
//!
//! How a tool call finds the II session it acts as is the one seam between
//! deployments: the hosted server resolves it per request from the
//! [`AuthedSession`] extension its bearer-token gate injects
//! ([`SessionSource::Bearer`]), while a single-user local server pins one
//! session established at login ([`SessionSource::Singleton`]). The tool
//! implementations are identical in both.
//! deployments, and **authentication itself stays in the embedding binary**:
//! each binary injects a [`SessionResolver`] reporting the already-validated
//! session for a call — the hosted server's reads what its bearer-token
//! middleware stashed on the request, a single-user local server's returns
//! the one session its login flow established. The tool implementations are
//! identical in both.
//!
//! The IC [`Agent`] is **inherited from the embedding application**, not built
//! here: the binary passes in its own agent, so anonymous canister calls go
Expand All @@ -36,7 +37,7 @@ mod discover;
mod management;

pub use identities::{IiInstance, SessionGauges};
pub use tools::{AuthedSession, IcTools, SessionSource};
pub use tools::{IcTools, SessionResolver};
/// The IC [`Agent`] type the components are built around, re-exported so
/// callers construct the injected agent from the exact `ic-agent` version this
/// crate links.
Expand Down
Loading
Loading