Skip to content

Bug confirmed in v3.6.1 — manual tokens from admin web are incompatible with /api/mcp (MCP StreamableHTTP), returns code 315 #472

Description

@cabatech

Bug confirmed in v3.6.1 — manual tokens from admin web are incompatible with /api/mcp (MCP StreamableHTTP), returns code 315

TL;DR: Issue #450 is not actually fixed in v3.6.1 (commit 7a6c78). The admin web "Create new token" form still produces a scope string that excludes the mcp protocol, so any MCP client connecting to /api/mcp gets {"code":315,"status":false,"message":"Auth token Scope restricted"}. Below: full diagnosis, workaround applied at the database level, and a one-line upstream fix proposal.


Disclaimer

I'm not a Go developer and I'm not going to submit a PR myself. I'm a sysadmin running FNS as a Docker container for personal use (Obsidian sync + MCP integration with an AI coding assistant). I used an AI coding agent to read the v3.6.1 source, locate the bug, and apply a workaround directly in the SQLite database. Everything below comes from that investigation. Take it with a grain of salt and please verify against the source before merging anything.


Symptom

curl -s -X POST https://<your-fns>/api/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer <manual-jwt-from-admin-web>" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'

Returns:

{"code":315,"status":false,"message":"Auth token Scope restricted","details":"Permission denied: /api/mcp"}

The JWT is valid (parses correctly, signature verified, GetActiveToken succeeds). The same token works perfectly on /api/note, /api/folder, etc. The failure is specific to /api/mcp.


Root cause

In v3.6.1 (commit 7a6c78792c631f999c8a5f725bba5dd7235d6688), internal/service/token_service.go:91-115 (Create) builds the scope like this when the admin web sends the legacy form payload:

formattedScope = "p:" + params.Scope + " c:" + params.ClientType + " f:*"

For a token created from the admin web UI (which submits Scope="rest,ws" + ClientType="ObsidianPlugin"), this produces:

p:rest,ws c:ObsidianPlugin f:*

Then in internal/middleware/user_auth_token.go:127-132:

protocol := "rest"
if strings.HasPrefix(path, "/api/mcp") {
    protocol = "mcp"
}
if path != "/api/health" && !app.VerifyPermissions(dbToken.Scope, protocol, reqClientType, function) {
    ...
    return ..., code.ErrorAuthTokenScopeRestricted.WithDetails("Permission denied: " + resPath)
}

For path /api/mcp, protocol becomes "mcp". pkg/app/permission.go:42-53 checks if "mcp" is in scopeP="rest,ws" — it's not — so matchP=falseVerifyPermissions returns false → 315.

Why no other lever works

Config lever Why it doesn't help
server.mcp-disable-localhost-protection: true Unrelated to scope checking
oauth.default-fns-scope: "..." (e.g. "p:mcp c:* f:*") Only consulted in the OAuth branch (internal/middleware/mcp_oauth.go:236-243). On the static JWT path (which is what admin web tokens use), it's dead code
Regenerate the JWT via admin web "Rotate" The DB scope stays p:rest,ws c:ObsidianPlugin f:*; rotation only changes the nonce
Click "Create new token" again Same bug — re-produces the same broken scope

Why matchC (client) isn't the issue here

The admin web form sends client_type="ObsidianPlugin", which matches. The middleware defaulting in user_auth_token.go:85-87 only applies to IssueType=1 (login) tokens on GET /api/file. So manual tokens (IssueType=2) keep whatever client_type is in DB, which is ObsidianPlugin — and the scope check is c:ObsidianPlugin vs the empty reqClientType. The 315 triggers on matchP first, before we even reach matchC.


Workaround applied (database level)

Two-part fix without recompiling:

1. Patch existing manual tokens

UPDATE auth_token
SET scope = 'p:rest,ws,mcp c:* f:*',
    updated_at = datetime('now')
WHERE issue_type = 2
  AND scope NOT LIKE '%mcp%'
  AND status = 1;

After this, the existing JWT will still fail (nonce is stale relative to DB token_string). You need to either:

  • Rotate the token via POST /api/token/<id>/rotate (which generates a fresh JWT matching the patched scope), or
  • Re-login through the admin web to get a new JWT

c:* (instead of c:ObsidianPlugin) is intentional: when OAuth.enabled=false and the request goes through UserAuthTokenWithConfig directly (not MCPOAuthWithConfig's OAuth branch), no X-Client header is auto-injected, and any MCP client that doesn't send x-client: ObsidianPlugin would fail matchC. c:* makes the token universally compatible.

2. Idempotent SQLite triggers (long-term guard)

Install once via the FNS container or any SQLite client with access to storage/database/db.sqlite3:

DROP TRIGGER IF EXISTS auth_token_scope_fix_insert;
DROP TRIGGER IF EXISTS auth_token_scope_fix_update;

-- Any future manual token (IssueType=2) created without 'mcp' in scope
-- gets auto-patched to include it.
CREATE TRIGGER auth_token_scope_fix_insert
AFTER INSERT ON auth_token
WHEN NEW.issue_type = 2 AND NEW.scope NOT LIKE '%mcp%'
BEGIN
  UPDATE auth_token
  SET scope = 'p:rest,ws,mcp c:* f:*',
      updated_at = datetime('now')
  WHERE id = NEW.id;
END;

-- Blocks any UPDATE that would remove 'mcp' from a manual token's scope.
CREATE TRIGGER auth_token_scope_fix_update
BEFORE UPDATE OF scope ON auth_token
WHEN NEW.issue_type = 2 AND NEW.scope NOT LIKE '%mcp%'
BEGIN
  SELECT RAISE(ABORT, 'Manual tokens (issue_type=2) must include mcp in scope');
END;

These are stored in the SQLite file itself, so they survive updates of the FNS Docker image (GORM AutoMigrate uses ALTER TABLE, not DROP/CREATE). They only get wiped if you docker compose down -v (volume wipe) — in which case a one-shot init container recreates them on next up.

Note: an AFTER INSERT trigger cannot modify NEW.* directly in SQLite, so the UI will momentarily display the unpatched scope in the response (the in-memory Go struct was already populated before the trigger fires). The DB ends up correct, which is what matters for subsequent GetActiveToken calls.


Suggested upstream fix (for the maintainer)

The cleanest fix is in internal/service/token_service.go:91-115. The legacy branch should either:

(a) Always include mcp in the default protocol list when no explicit protocol is given:

formattedScope = "p:" + params.Scope + ",mcp c:" + params.ClientType + " f:*"

(with a small guard to avoid duplicating mcp,mcp if the form already sends it), or

(b) Add an explicit ProtocolMCP bool flag to dto.TokenIssueRequest so the admin web form can opt-in to MCP support, and the service appends mcp to the protocols only when requested.

Option (a) is the smaller change and matches what the test file internal/middleware/mcp_mixed_auth_test.go:71 already documents as the expected scope shape (p:mcp c:ObsidianPlugin f:*).

A regression test would be worth adding in internal/service/token_service_test.go:

func TestCreate_ManualToken_DefaultScopeIncludesMCP(t *testing.T) {
    // Call Create with Scope="rest,ws", ClientType="ObsidianPlugin"
    // Assert resulting token.Scope contains "mcp"
}

Other observations (no fix required, just FYI)

  • MCPOAuthWithConfig already supports default-fns-scope correctly when OAuth is enabled — only the static-token path is broken.
  • The OAuth scope mapper at internal/oauth/scope_mapper.go:44 correctly produces p:mcp c:<client> f:<functions> from OAuth claims — so OAuth-issued tokens work fine for /api/mcp. The bug is specific to manual tokens created via the admin web.
  • The middleware correctly distinguishes matchP failures from matchC failures (different code paths), but both surface as the same code 315 with a slightly different details field. Not a bug, just confusing during debugging.

Offer to help

I can't write or test Go code, but I'm happy to:

  • Re-run the diagnostic against any future v3.6.x / v3.7.x release if you want to verify the fix
  • Provide more debug output (auth_token_log, network traces) if it helps
  • Test a beta image that includes the fix

Thanks for maintaining FNS — it's a great tool.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions