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=false → VerifyPermissions 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.
Bug confirmed in v3.6.1 — manual tokens from admin web are incompatible with
/api/mcp(MCP StreamableHTTP), returns code 315Disclaimer
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
Returns:
{"code":315,"status":false,"message":"Auth token Scope restricted","details":"Permission denied: /api/mcp"}The JWT is valid (parses correctly, signature verified,
GetActiveTokensucceeds). 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:For a token created from the admin web UI (which submits
Scope="rest,ws"+ClientType="ObsidianPlugin"), this produces:Then in
internal/middleware/user_auth_token.go:127-132:For path
/api/mcp,protocolbecomes"mcp".pkg/app/permission.go:42-53checks if"mcp"is inscopeP="rest,ws"— it's not — somatchP=false→VerifyPermissionsreturnsfalse→ 315.Why no other lever works
server.mcp-disable-localhost-protection: trueoauth.default-fns-scope: "..."(e.g."p:mcp c:* f:*")internal/middleware/mcp_oauth.go:236-243). On the static JWT path (which is what admin web tokens use), it's dead codep:rest,ws c:ObsidianPlugin f:*; rotation only changes the nonceWhy
matchC(client) isn't the issue hereThe admin web form sends
client_type="ObsidianPlugin", which matches. The middleware defaulting inuser_auth_token.go:85-87only applies to IssueType=1 (login) tokens onGET /api/file. So manual tokens (IssueType=2) keep whatever client_type is in DB, which isObsidianPlugin— and the scope check isc:ObsidianPluginvs the emptyreqClientType. The 315 triggers onmatchPfirst, before we even reachmatchC.Workaround applied (database level)
Two-part fix without recompiling:
1. Patch existing manual tokens
After this, the existing JWT will still fail (nonce is stale relative to DB
token_string). You need to either:POST /api/token/<id>/rotate(which generates a fresh JWT matching the patched scope), orc:*(instead ofc:ObsidianPlugin) is intentional: whenOAuth.enabled=falseand the request goes throughUserAuthTokenWithConfigdirectly (notMCPOAuthWithConfig's OAuth branch), noX-Clientheader is auto-injected, and any MCP client that doesn't sendx-client: ObsidianPluginwould failmatchC.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: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 nextup.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
mcpin the default protocol list when no explicit protocol is given:(with a small guard to avoid duplicating
mcp,mcpif the form already sends it), or(b) Add an explicit
ProtocolMCP boolflag todto.TokenIssueRequestso the admin web form can opt-in to MCP support, and the service appendsmcpto the protocols only when requested.Option (a) is the smaller change and matches what the test file
internal/middleware/mcp_mixed_auth_test.go:71already 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:Other observations (no fix required, just FYI)
MCPOAuthWithConfigalready supportsdefault-fns-scopecorrectly when OAuth is enabled — only the static-token path is broken.internal/oauth/scope_mapper.go:44correctly producesp: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.matchPfailures frommatchCfailures (different code paths), but both surface as the same code 315 with a slightly differentdetailsfield. Not a bug, just confusing during debugging.Offer to help
I can't write or test Go code, but I'm happy to:
Thanks for maintaining FNS — it's a great tool.