Repository navigation
Package the MCP server as an embeddable library crate - #26
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the MCP server into an embeddable Rust library crate (ic-mcp) plus a thin deployment binary (mcp-poc), exposing two axum routers: one for the MCP + OAuth surface (nestable anywhere) and one for /.well-known/* discovery (merged at the app root, parameterized by the MCP mount path).
Changes:
- Introduces
ic_mcp::McpServeras the public API, moving the MCP tools intosrc/tools.rsand keepingsrc/main.rsas a deployment wrapper. - Relocates OAuth endpoints under
{mcp_mount}/oauth/*and updates discovery + monitoring probes to use the path-aware metadata and challenge. - Adds router-level contract tests to pin mount-path behavior, discovery docs, and the 401
WWW-Authenticatechallenge behavior.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/routers.rs | New router composition/contract tests for mount-path-aware discovery and auth flows. |
| src/tools.rs | Moves MCP tool handler implementation into the library crate. |
| src/main.rs | Converts binary into a thin deployment wrapper composing McpServer routers + ops niceties. |
| src/lib.rs | New library crate entry exposing McpServer::{new, mcp_router, well_known_router, shutdown}. |
| src/auth.rs | Updates OAuth flow to be mount-path-aware; adds PKCE enforcement and redirect URI hardening. |
| README.md | Updates docs for library usage, endpoint paths, and probing guidance. |
| monitoring/mcp-status/README.md | Updates monitoring docs for new OAuth endpoint paths. |
| monitoring/mcp-status/config.js | Updates inline references to the authorize endpoint path. |
| monitoring/mcp-status/checks.test.js | Updates tests to reflect new discovery + OAuth endpoint URLs and challenge location. |
| monitoring/mcp-status/checks.js | Updates probing logic to use new OAuth paths and path-aware resource metadata challenge. |
| Dockerfile | Updates build-time note to reflect tool static includes. |
| Cargo.toml | Renames crate to ic-mcp, adds [[bin]] for mcp-poc, adds dev-deps for router tests. |
| Cargo.lock | Reflects crate rename and dependency graph updates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
8d5a464 to
7a2d503
Compare
7a2d503 to
36278d3
Compare
36278d3 to
6c2dc99
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/auth.rs:469
- The new requirement
code_challenge_method == Some("S256")is security/interop-critical (RFC 7636 defaults an omitted method toplain). I couldn't find a test that exercises the valid, registered client case wherecode_challenge_methodis missing or non-"S256" to ensure this check can’t be accidentally reordered behind client validation or removed in refactors.
Add a regression test that registers a client, calls /oauth/authorize with a missing/invalid code_challenge_method, and asserts a 400 invalid_request.
// `token` only ever verifies S256, so require the method to say so
// EXPLICITLY. Per RFC 7636 an *omitted* method defaults to `plain` —
// accepting the omission and then verifying as S256 would hand a
// spec-strict `plain` client a code it can never exchange.
if q.code_challenge_method.as_deref() != Some("S256") {
return oauth_err(StatusCode::BAD_REQUEST, "invalid_request", "code_challenge_method=S256 is required");
}
Restructure the binary into a library crate (imcp2) plus a thin deployment
binary, so one McpServer per Internet Identity instance mounts into any axum
application as two routers:
* McpServer::mcp_router() — the MCP streamable-HTTP endpoint (the router
fallback: the bare mount path, its trailing-slash form, and sub-paths all
reach it, bearer-gated + CORS'd) plus the OAuth authorization server under
/oauth (authorize, pinned connect-callback page, redeem, token, register)
and the OIDC-style AS-metadata alternate, nested at McpServer::mcp_path().
* McpServer::well_known_router() — the discovery documents at their
RFC 8414 / RFC 9728 §3.1 path-inserted well-known locations, merged at the
application root and parametric on the mount path: the AS issuer is
{public_url}{mcp_path} and every OAuth endpoint lives under it.
root_well_known_router() adds the plain-root fallback documents for the
origin's default instance, and auth_callbacks_router(&[…]) the
origin-global II auth-callback allow-list covering every instance.
The IC Agent is inherited from the embedding application (McpConfig::agent),
not built by the library, so a host passes its own boundary-node client and
the process links one ic-agent. Sourcing ic-agent through ic-bn-lib is
deferred until it releases against ic-agent 0.48 (its current release pins
0.47, which would revert the read-only delegation-permissions support);
recorded in Cargo.toml.
Consequences and companion changes:
* Both instances become path issuers with OAuth under their mounts:
beta /mcp + /mcp/oauth/* (issuer <PUBLIC_URL>/mcp — was root /oauth/* with
a root issuer), prod /mcp-prod + /mcp-prod/oauth/* (issuer
<PUBLIC_URL>/mcp-prod — was /prod/oauth/*). The 401 challenge points at
the path-inserted resource-metadata document; root discovery documents
are still served for the default (beta) instance as a courtesy. The
connect flow (binding cookie Path, II link callback, pinned page's redeem
URL, the ii-auth-callbacks entries) derives from the mount and is covered
by tests.
* IiInstance sheds oauth_prefix/mcp_path — it is purely "which II" now; the
mount path is deployment composition (McpConfig). PUBLIC_URL is no longer
read inside the library: the binary passes it in, and Identities receives
it for the management-identity derivation origin (unchanged value, so
derived principals are unaffected).
* The session reaper and live/active session gauges are exposed on
McpServer (spawn_session_reaper / session_gauges) for the binary's
/version probe; drained shutdown is preserved (drain, then shutdown()).
* Monitoring dashboard probes/tests and the READMEs updated to the new
endpoints; the register probe now uses a loopback redirect_uri (the
hosted-redirect allow-list from #69 rejects the old example.org probe —
a pre-existing red check against current main).
* New router-level contract tests (tests/routers.rs) pin the two-instance
composition: per-instance discovery documents and issuers, the
origin-global auth-callback document, path-aware challenges (incl.
trailing-slash/sub-path breadth and the RFC 6750 error attribute), and
per-mount OAuth endpoints incl. the pinned page's redeem URL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
…E method, robust Host allow-list Three review findings on the library extraction: * Delegated/II calls now ride the injected agent's routing. Identities keeps the base agent (McpConfig::agent) and every identity-bearing agent is a clone of it with the identity swapped (Agent::set_identity — the clone shares the transport, route provider, and root key). This removes every hard-coded IC_URL rebuild (per-app calls, session/registration II calls, the management agent), so an embedding that targets a non-default IC endpoint routes ALL calls through it, not just anonymous ones. * authorize rejects an omitted code_challenge_method. Per RFC 7636 an omitted method defaults to `plain`, and the token endpoint only verifies S256 — accepting the omission would hand a spec-strict `plain` client a code it can never exchange. S256 must now be stated explicitly (it is the only method the metadata advertises). * The DNS-rebinding Host allow-list is derived by parsing public_url as a URL (assuming https when scheme-less) instead of string-splitting, and includes both the bare-host and host:port forms — a mis-parse here would reject all public traffic before the bearer check. Covered by unit tests for the scheme-less, explicit-port, and IPv6-literal shapes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
6c2dc99 to
812b67e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
tests/routers.rs:27
- These integration tests call
SharedClients::load(), which readsOAUTH_CLIENTS_FILE(defaultoauth-clients.json). That makes the tests non-hermetic: a developer’s local persisted registrations can change behavior (e.g. make a supposedly-unknownclient_idvalid) and cause flaky failures. ForceOAUTH_CLIENTS_FILEto a temp path once for this test binary before constructing servers.
instance,
public_url: PUBLIC_URL.into(),
mcp_path: mcp_path.into(),
clients: imcp2::SharedClients::load(),
})
… (Copilot review) * tests/routers.rs: point OAUTH_CLIENTS_FILE at a throwaway temp path before SharedClients::load(), so the router contract tests neither read nor write a developer's real oauth-clients.json. The authorize-path assertions depend on an empty registration set (unknown clients rejected); a stray real registration for client_id=unknown/x would otherwise flip them. * src/lib.rs: seed the DNS-rebinding Host allow-list with IPv6 loopback in both the bare (::1) and bracketed ([::1]) spellings. A real Host header serializes IPv6 bracketed, matching the host_str() form the public-host branch already emits; carrying both keeps the list self-consistent and independent of rmcp's bracket normalization. Unit test updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
tests/routers.rs:29
isolate_client_storeuses a fixed filename in the system temp dir. Because Rust tests (and integration test binaries) can run in parallel, this can create cross-process interference/flakiness if another test process (or a concurrentcargo testrun) touches the sameOAUTH_CLIENTS_FILEpath. Use a per-process-unique filename (e.g., includestd::process::id()) to avoid collisions while still setting the env var only once per process.
let mut path = std::env::temp_dir();
path.push("imcp2-router-tests-oauth-clients.json");
let _ = std::fs::remove_file(&path); // drop any stale file from a prior run
…re_valid (Copilot review) IiInstance::beta()/prod() read II_URL* / II_CANISTER_ID* from the environment, so asserting the two instances differ fails on a legitimate setup that points both at the same II (e.g. local testing) or sets only one var set. The test's real purpose is that the compile-time default canister ids parse into a Principal, which the two .expect()s already validate; drop the assert_ne!. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
No source changes needed — the 0.49 API surface this crate uses (Agent, Agent::builder, set_identity, DelegatedIdentity/BasicIdentity and the identity/delegation types, read_state_canister_metadata, query/update) is unchanged from 0.48. Cargo.lock pins both ic-agent and ic-transport-types at exactly 0.49.0 (kept in lockstep, as prior bumps were). Full suite green and the binary boots and serves against 0.49. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
tests/routers.rs:28
- The temp oauth-clients file name is a fixed path in the global temp dir. If two
cargo testprocesses run concurrently on the same machine, they can race on this shared filename (delete/write) and make these router tests flaky. Include the process id (or other unique suffix) in the filename to avoid cross-process collisions.
path.push("imcp2-router-tests-oauth-clients.json");
…cookie check (Copilot review)
Two related public_url-hygiene findings:
* McpServer::new now canonicalizes public_url to an origin (scheme://host[:port])
via normalize_public_url: adds https:// when scheme-less, lowercases the
scheme, drops the default port, and strips any path/query/fragment. It is the
single source of truth for the AS issuer, discovery docs, II callback, the
Secure-cookie decision, and the DNS-rebinding allow-list, so a scheme-less
host, an uppercase scheme, or a stray path previously produced malformed
issuers/callbacks. Self-corrects (no API break); falls back to the trimmed
input if a host can't be parsed. Unit-tested across those shapes.
* authorize's Secure-cookie check compares the scheme case-insensitively
(split_once("://") + eq_ignore_ascii_case) instead of starts_with("https://"),
so an uppercase scheme still yields Secure even for a directly-built AuthStore
(belt-and-suspenders on top of the normalization above).
Real deployments (PUBLIC_URL already a clean origin) are unaffected — the
normalization is a no-op there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
tests/routers.rs:31
- These router tests use a fixed filename in the OS temp dir for OAUTH_CLIENTS_FILE. If the test suite is run concurrently in multiple processes (e.g., two
cargo testinvocations in parallel on the same machine/CI runner), they can race and interfere via the shared temp file.
let mut path = std::env::temp_dir();
path.push("imcp2-router-tests-oauth-clients.json");
let _ = std::fs::remove_file(&path); // drop any stale file from a prior run
std::env::set_var("OAUTH_CLIENTS_FILE", &path);
});
Follow-up reconciliation after #26 (library crate), #70, #73, #74 landed on main. Verified against the current source; three concrete fixes: - Deploy: the connect/landing HTML/CSS/SVG live in `src/assets/` (compiled in via include_str! from src/main.rs and src/auth.rs), not `static/`; `static/` holds only the reference docs. The old text conflated the two and contradicted the "Browser-facing error screens" section. Reframed as the deployment binary being self-contained, with both dirs as build-time inputs. - Intro: ic-agent pin is 0.49 (Cargo.toml), not 0.48. - Tools table: `canister_query` and `canister_update_call` take an optional `candid` arg (the .did fallback when a canister publishes no candid:service metadata); added it to both rows. A wider audit found the rest of the doc already matches main (tool set, endpoints, OAuth path-pinned allow-list, env vars, II methods, library API). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
Summary
Restructures the server into a Rust library crate (
imcp2) plus a thin deployment binary, with the requested router structure. OneMcpServerserves one Internet Identity instance as two axum routers:McpServer::mcp_router()— one router serving…/mcp/{MCP_API}and…/mcp/{OAUTH_API}: the MCP streamable-HTTP endpoint as the router fallback (bare mount path, trailing-slash form, and sub-paths all reach it — the same breadthnest_servicegives it today), bearer-gated with CORS outside the gate; the OAuth AS (authorize, pinnedconnect/callbackpage,connect/redeem,token,register) under/oauth; and the OIDC-style AS-metadata alternate at{mount}/.well-known/oauth-authorization-server.McpServer::well_known_router()— the/.well-known/{WELL_KNOWN_HANDLERS}, merged at the app root and parametric on the relative path to the mcp mount: the AS issuer is{public_url}{mcp_path}(RFC 8414 path issuer) and the documents live at the path-inserted locations.root_well_known_router()adds the plain-root fallback docs for the origin's default instance;auth_callbacks_router(&[…])serves the origin-global/.well-known/ii-auth-callbackscovering every instance (II #4091 is fail-closed, so its entries must — and are tested to — match the II links' callbacks byte-for-byte).Still axum throughout.
Agent inheritance & ic-bn-lib
The IC
Agentis injected by the embedding application (McpConfig::agent), not built by the library — a boundary node or gateway passes its own route-configured client, so the process links oneic-agent. Sourcingic-agentthroughic-bn-libis deferred: its current release pins ic-agent 0.47, and adopting it would revert #33's read-only delegation-permissions support (which needs the 0.48 this repo now uses). Recorded as a comment at the dep inCargo.toml; revisit when ic-bn-lib releases against 0.48.What changed
src/lib.rs(public API:McpServer::{new, mcp_path, mcp_router, well_known_router, root_well_known_router, spawn_session_reaper, session_gauges, shutdown},McpConfig,IiInstance,SharedClients,auth_callbacks_router,Agent/ic_agentre-exports,IC_URL) andsrc/tools.rs(the MCP tools, moved byte-identical out ofmain.rs— verified by diff — modulo twopub(crate)keywords);src/main.rsis now a thin consumer composing beta (/mcp) + prod (/mcp-prod) and keeping the deployment machinery (landing page,/versionwith live/active session gauges, drain-then-cancel shutdown)./oauth/*→/mcp/oauth/*(issuer<PUBLIC_URL>→<PUBLIC_URL>/mcp), prod/prod/oauth/*→/mcp-prod/oauth/*. The 401 challenge points at the path-inserted resource-metadata doc; root discovery docs are kept for beta as a courtesy. The whole connect flow — binding-cookiePath, II link callback, pinned page's redeem URL,ii-auth-callbacksentries — derives from the mount, covered by unit + contract tests.IiInstanceslims down to purely which II;oauth_prefix/mcp_pathwere deployment concerns and now live inMcpConfig.PUBLIC_URLis no longer read inside the library; the management-identity derivation origin is the same value as before, so derived principals are unchanged.https://example.org/callbackprobe is rejected by Close auth-code phishing via a hosted-redirect allow-list #69's hosted-redirect allow-list, i.e. that check is red against currentmainalready; this fixes it in passing.imcp2(upstream's Add NOTICE file and rename project mcp-poc → IMCP2 #68 rename supersedes the earlieric-mcpidea); addingsrc/lib.rsgives the same package a library target with no Dockerfile/deploy changes.Testing
cargo build/test --locked --all-targets(CI's exact invocation): 129 lib tests + 7 new router contract tests pass; the doc example compiles.ii-auth-callbacksdocument listing both mounts, path-aware 401 challenges incl. trailing-slash/sub-path breadth and the RFC 6750error="invalid_token"attribute, authorize input validation, per-mount token endpoints, and each pinned callback page posting to its own instance's redeem path.Path=/mcp/oauth), pinned pages for both instances — verified with curl.npm test(17 tests) and the monitoring CLI against the live server: every endpoint check passes.🤖 Generated with Claude Code
https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj