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
83 changes: 71 additions & 12 deletions Cargo.lock

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

40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,46 @@ Replace `--db <path>` with `--memory` for an ephemeral, in-memory graph.

> stdout is reserved for the JSON-RPC stream; logs are written to stderr.

### Remote (HTTP) connector

Build with the HTTP transport and run cortex normally; the MCP endpoint is served at `/mcp`:

```bash
cargo build -p aingle_cortex --features "mcp dag mcp-http" --release

AINGLE_MCP_HTTP_TOKEN=your-secret AINGLE_PUBLIC_HOST=your.domain \
aingle-cortex --db ./data/graph.sled
# MCP available at http://localhost:19090/mcp
# Clients send: Authorization: Bearer your-secret
```

- The `/mcp` route is **only mounted** when a bearer token is set (`--mcp-http-token` / `AINGLE_MCP_HTTP_TOKEN`) or `--mcp-http-allow-anonymous` is passed — it is never exposed unintentionally.
- `AINGLE_PUBLIC_HOST` (comma-separated) must list the public hostname(s) for a remote deployment (rmcp rejects non-loopback `Host` headers otherwise).
- `--mcp-http-allow-anonymous` serves `/mcp` without auth (test only).

> Note: claude.ai's connector UI cannot attach a static bearer header; secured remote use from claude.ai needs OAuth (planned). Verify the deployed endpoint with `curl`/MCP Inspector using the bearer token.

#### OAuth (secured remote access)

Build with `--features "mcp dag mcp-http mcp-oauth"` and set an issuer; cortex then acts as an OAuth 2.0
Resource Server for `/mcp` (e.g. for claude.ai remote connectors):

```bash
AINGLE_OAUTH_ISSUER=https://auth.example/realms/aingle \
AINGLE_OAUTH_RESOURCE=https://mcp.example/mcp \
aingle-cortex --db ./data/graph.sled
```

- Serves `GET /.well-known/oauth-protected-resource` (RFC 9728); a request to `/mcp` without a valid token
gets `401` + `WWW-Authenticate: Bearer resource_metadata="…"` so clients can discover the authorization server.
- `/mcp` accepts a Bearer **JWT** signed by the issuer — validated via its JWKS, algorithm pinned to RS256,
with `iss`, `aud` (must equal the resource), and `exp` all required.
- The Phase-1 static bearer (`AINGLE_MCP_HTTP_TOKEN`) is still accepted alongside OAuth (handy for `curl`).
This dual-credential behavior is intentional; a leaked static token bypasses the JWT checks, so use it only
where appropriate.
- For non-Keycloak issuers, set `AINGLE_OAUTH_JWKS_URL` explicitly (the default derives the Keycloak certs path).
The Authorization Server (login, PKCE, client registration) is external — see the private deploy repo.

---

## Contributing
Expand Down
7 changes: 6 additions & 1 deletion crates/aingle_cortex/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ p2p-mdns = ["p2p", "dep:mdns-sd", "dep:if-addrs"]
cluster = ["p2p", "dep:aingle_wal", "dep:aingle_raft", "dep:openraft", "dep:tokio-rustls", "dep:rustls-pemfile"]
dag = ["cluster", "aingle_graph/dag", "aingle_graph/dag-sign", "aingle_raft/dag"]
mcp = ["dep:rmcp", "dep:schemars"]
full = ["rest", "graphql", "sparql", "auth", "dag"]
mcp-http = ["mcp", "rmcp/transport-streamable-http-server", "rmcp/server-side-http"]
mcp-oauth = ["mcp-http", "dep:jsonwebtoken"]
full =["rest", "graphql", "sparql", "auth", "dag"]

[[bin]]
name = "aingle-cortex"
Expand Down Expand Up @@ -119,3 +121,6 @@ tokio-test = "0.4"
# Enable the rmcp `client` feature for the in-process MCP integration test.
# This is dev-only; the production `mcp` feature uses the server side only.
rmcp = { version = "1.7", features = ["client", "transport-io"] }
# OAuth integration test: derive a JWK from the test public key + base64url encode.
rsa = { version = "0.9", features = ["pem"] }
base64 = "0.22"
56 changes: 56 additions & 0 deletions crates/aingle_cortex/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"--mcp" => {
config.mcp_mode = true;
}
"--mcp-http-token" => {
if i + 1 < args.len() {
config.mcp_http_token = Some(args[i + 1].clone());
i += 1;
}
}
"--mcp-http-allow-anonymous" => {
config.mcp_http_allow_anonymous = true;
}
"--mcp-oauth-issuer" => {
if i + 1 < args.len() {
config.mcp_oauth_issuer = Some(args[i + 1].clone());
i += 1;
}
}
"--mcp-oauth-resource" => {
if i + 1 < args.len() {
config.mcp_oauth_resource = Some(args[i + 1].clone());
i += 1;
}
}
"--mcp-oauth-jwks-url" => {
if i + 1 < args.len() {
config.mcp_oauth_jwks_url = Some(args[i + 1].clone());
i += 1;
}
}
"--flush-interval" => {
if i + 1 < args.len() {
config.flush_interval_secs = args[i + 1].parse().unwrap_or(300);
Expand All @@ -89,6 +116,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
i += 1;
}

// Fall back to the environment for the MCP HTTP bearer token if not given as a flag.
if config.mcp_http_token.is_none() {
config.mcp_http_token = std::env::var("AINGLE_MCP_HTTP_TOKEN").ok();
}
if config.mcp_oauth_issuer.is_none() {
config.mcp_oauth_issuer = std::env::var("AINGLE_OAUTH_ISSUER").ok();
}
if config.mcp_oauth_resource.is_none() {
config.mcp_oauth_resource = std::env::var("AINGLE_OAUTH_RESOURCE").ok();
}
if config.mcp_oauth_jwks_url.is_none() {
config.mcp_oauth_jwks_url = std::env::var("AINGLE_OAUTH_JWKS_URL").ok();
}

// If --mcp was requested but the binary was built without the `mcp` feature,
// fail loudly instead of silently falling through to the TCP REST server.
#[cfg(not(feature = "mcp"))]
Expand Down Expand Up @@ -299,6 +340,21 @@ fn print_help() {
println!(" --memory Use volatile in-memory storage (no persistence)");
println!(" --flush-interval <S> Periodic flush interval in seconds (default: 300, 0=off)");
println!(" --mcp Serve MCP over stdio (requires --features mcp)");
println!(
" --mcp-http-token <T> Bearer token for the /mcp HTTP endpoint (requires --features mcp-http)"
);
println!(
" --mcp-http-allow-anonymous Serve /mcp without auth (test mode; requires --features mcp-http)"
);
println!(
" --mcp-oauth-issuer <U> OAuth issuer URL; enables OAuth on /mcp (requires --features mcp-oauth)"
);
println!(
" --mcp-oauth-resource <R> OAuth protected-resource id = expected JWT audience (requires --features mcp-oauth)"
);
println!(
" --mcp-oauth-jwks-url <U> Explicit JWKS URL; derived from issuer if omitted (requires --features mcp-oauth)"
);
println!(" -V, --version Print version and exit");
println!(" --help Print this help message");
println!();
Expand Down
Loading
Loading