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
32 changes: 32 additions & 0 deletions .changeset/missing-credentials-say-so.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"fiber": minor
---

A containerised MCP server now says when it has no credential for a collection,
instead of giving desktop advice.

The credentials file landed in 0.15.0, but only for people who migrated onto it.
A workload created before it goes on reading its frozen `FIBER_SECRETS` snapshot,
and a collection authenticated after that workload started is not stale in the
snapshot — it is absent. There is no 401, so nothing refreshes and nothing
retries; the send fails before it is made, with "not signed in — open Section
settings and sign in". In a container there are no Section settings, no window
to sign in through, and usually a user who *is* signed in.

That failure now explains itself. It names the collection and the reference,
says which source the server reads, and — for a snapshot — that the source was
frozen when the workload started and cannot pick a later sign-in up. The server
also reports its credential source at startup and warns there about every shared
collection whose credential it cannot see, and `list_sections` marks those with
`"credential": "missing"` so an agent finds out before spending a call.

Silently re-captured browser credentials reach the file too. On a 401 the app
lifts a fresh one out of a hidden webview and deliberately does not write it to
the keychain, because writing costs a password prompt. None of that reasoning
applies to the credentials file — the sealing key is already cached and the
write is a file write — but it was being skipped all the same, so a container
only ever saw a browser credential change on an explicit sign-in.

Rerunning `scripts/toolhive.sh` migrates a pre-0.15 workload, and now says so
when it does, including that the old snapshot is left behind holding stale
credentials.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ It finds your collections, copies the credentials for the collections you have
shared straight from the keychain into ToolHive's encrypted store, and starts
the server. Nothing is typed twice and nothing is pasted.

Rerun it to migrate a server set up before Fiber 0.15, which reads a snapshot of
your credentials frozen when it started — see
[Migrating from `FIBER_SECRETS`](deploy/toolhive.md#migrating-from-fiber_secrets).

See [`deploy/toolhive.md`](deploy/toolhive.md) for what each step does, and for
running it by hand.

Expand Down
38 changes: 38 additions & 0 deletions deploy/toolhive.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ presenting the old credential until you re-export and replace the workload —
rerunning `toolhive.sh` does both. That is the behaviour the file above exists
to avoid.

Worth being precise about what "old credential" covers, because the sharper case
is easy to miss: a collection you authenticated *after* the workload started is
not in the snapshot at all. It fails with no credential rather than a rejected
one, so there is no 401 and nothing to refresh — see
[Migrating from `FIBER_SECRETS`](#migrating-from-fiber_secrets).

If you have no app on the machine, the same map typed by hand does the same job:

```sh
Expand All @@ -163,6 +169,38 @@ app would have put in the keychain.
you manage yourself; setting the key and pointing it at a plaintext file is an
error rather than a silent downgrade, and so is an encrypted file with no key.

### Migrating from `FIBER_SECRETS`

A workload created before the credentials file existed goes on reading its
snapshot, and nothing announces it: the container is healthy, the collections it
knew about still work, and only a collection you authenticated *after* the
workload started fails — with the credential simply absent rather than expired.
The symptom is "it works in the app but not over MCP".

Rerunning the install migrates it, because the script replaces the workload:

```sh
curl -fsSL https://raw.githubusercontent.com/MathiasWP/fiber/main/scripts/toolhive.sh | bash
```

Afterwards the old snapshot is unused, and it is a stale copy of every
credential you had the day it was taken, so take it away:

```sh
thv secret delete fiber-secrets
```

Two things will tell you which scheme a running server is on. It logs its source
at startup —

```
credentials: FIBER_SECRETS, a snapshot frozen at startup
```

— and warns there about any shared collection whose credential it cannot see.
`list_sections` marks the same collections with `"credential": "missing"`, so an
agent finds out before spending a call rather than after.

### Why this is not as good as the keychain

Inside a container, injected secrets live in the process environment or a
Expand Down
28 changes: 28 additions & 0 deletions scripts/toolhive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ NAME="fiber"
# the mounted collections directory, so the app can keep them current.
SECRET_KEY="fiber-key"
SECRETS_FILE="mcp-secrets.enc"
# What the pre-0.15 setup used: a JSON snapshot of every credential, injected
# as FIBER_SECRETS. Replaced by the file above, and named here only so a rerun
# can tell you it is now dead weight.
LEGACY_SECRET="fiber-secrets"

die() {
echo "$*" >&2
Expand Down Expand Up @@ -65,8 +69,16 @@ fi

# A rerun should replace the workload rather than collide with it. `thv rm`
# takes the container away; the collections and the secret both outlive it.
#
# This is also the only migration path off the pre-0.15 setup, and nothing else
# will prompt anyone to take it: a workload created back then goes on reading a
# frozen FIBER_SECRETS snapshot, quietly, for as long as it runs. Saying so here
# is the difference between "my new collection 401s over MCP but works in the
# app" and knowing why.
replaced=""
if thv list --all 2> /dev/null | grep -q "^$NAME[[:space:]]"; then
echo "Replacing the existing '$NAME' workload..."
replaced="yes"
thv stop "$NAME" > /dev/null 2>&1 || true
thv rm "$NAME" > /dev/null 2>&1 || true
fi
Expand Down Expand Up @@ -129,5 +141,21 @@ fi

echo
echo "Done. '$NAME' is serving $data."

# The snapshot outlives the workload that used it, and ToolHive gives no sign
# that nothing reads it any more. Left in place it is a stale copy of every
# credential you had the day it was taken, so it is worth saying out loud.
if [ -n "$replaced" ] && [ "${#secret_args[@]}" -ne 0 ] &&
thv secret list 2> /dev/null |
grep -q -- "^[[:space:]]*-[[:space:]]*$LEGACY_SECRET\$"; then
echo
echo "This workload reads the credentials file, so signing in again in Fiber"
echo "reaches it without a rerun. The '$LEGACY_SECRET' snapshot it used before is"
echo "no longer read by anything, and holds whatever your credentials were the"
echo "day it was taken. Remove it with:"
echo " thv secret delete $LEGACY_SECRET"
fi

echo
echo "Check it with: thv list"
echo "Point a client at it with: thv client setup"
29 changes: 25 additions & 4 deletions src-tauri/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//! credential (it just can't capture a new one, having no UI).

use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Mutex;
use std::time::Duration;
Expand Down Expand Up @@ -747,11 +748,14 @@ pub async fn silent_recapture(app: &AppHandle, section: &Section) -> Result<Stri
/// `send` module.
pub struct BrowserRecapture {
app: AppHandle,
/// The data directory, for the credentials file a containerised server
/// reads. See `recapture`.
data: PathBuf,
}

impl BrowserRecapture {
pub fn new(app: AppHandle) -> Self {
Self { app }
pub fn new(app: AppHandle, data: PathBuf) -> Self {
Self { app, data }
}
}

Expand All @@ -761,9 +765,26 @@ impl Recapturer for BrowserRecapture {
section: &'a Section,
) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
Box::pin(async move {
silent_recapture(&self.app, section)
let value = silent_recapture(&self.app, section)
.await
.map_err(|err| err.to_string())
.map_err(|err| err.to_string())?;

// The keychain is skipped for a silent re-capture — writing one
// costs a password prompt on a build that cannot hold an ACL, and
// `send` explains why that trade is not worth making here. None of
// it carries over to the credentials file: the sealing key is
// already cached for the life of the process and the write is a
// file write, so the cost is nothing, and the file is the only
// channel a containerised server has.
//
// Without this, the token a container holds only ever moved on an
// explicit sign-in: the app could refresh a browser credential
// silently all day and the server would go on presenting the one it
// started with — the very failure the file was added to end.
if let Some(reference) = section.auth.secret_ref() {
crate::mcp::sync_secrets_file(&self.data, reference, Some(&value));
}
Ok(value)
})
}
}
Expand Down
23 changes: 19 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ mod gui {

let at = history::now_millis();
let url = spec.url.clone();
let recapture = BrowserRecapture::new(app.clone());
let recapture = BrowserRecapture::new(app.clone(), paths.data.clone());

// The channel is the only reason the body is on the bridge at all
// mid-flight. A send that fails means the window has gone; the request
Expand Down Expand Up @@ -390,17 +390,20 @@ mod gui {
app: &AppHandle,
http_state: &Arc<HttpState>,
auth_state: &Arc<AuthState>,
data: &Path,
section: &Section,
) -> loader::Fetcher {
let http = http_state.clone();
let auth = auth_state.clone();
let app = app.clone();
let data = data.to_path_buf();
let section = section.clone();

Arc::new(move |request: loader::LoaderRequest| {
let http = http.clone();
let auth = auth.clone();
let app = app.clone();
let data = data.clone();
let section = section.clone();

Box::pin(async move {
Expand All @@ -425,7 +428,7 @@ mod gui {
..Default::default()
};

let recapture = BrowserRecapture::new(app);
let recapture = BrowserRecapture::new(app, data);
let response = send_authenticated(
&http,
&auth,
Expand Down Expand Up @@ -471,7 +474,13 @@ mod gui {
) -> Result<LoaderRun, LoaderError> {
let section = section_for_loader(&paths, &sections, &section_id)?;
let config = section.loader.clone().ok_or(LoaderError::NoUrl)?;
let fetcher = loader_fetcher(&app, http_state.inner(), auth_state.inner(), &section);
let fetcher = loader_fetcher(
&app,
http_state.inner(),
auth_state.inner(),
&paths.data,
&section,
);

let (endpoints, schemas, response_schemas, pages) = loader::run(&config, fetcher).await?;

Expand Down Expand Up @@ -517,7 +526,13 @@ mod gui {
return Err(LoaderError::NoUrl);
}

let fetcher = loader_fetcher(&app, http_state.inner(), auth_state.inner(), &section);
let fetcher = loader_fetcher(
&app,
http_state.inner(),
auth_state.inner(),
&paths.data,
&section,
);
let method = match config.method.trim() {
"" => "GET".to_string(),
method => method.to_string(),
Expand Down
Loading
Loading