Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 11x 11x 11x 8x 3x 3x 3x 20x 41x 41x 41x 39x 2x 2x 2x 20x 20x 14x 14x 14x 2x 2x 13x 7x 7x 20x 5x 5x 5x 5x 3x 3x 20x 60x 60x 5x 3x 2x 1x 1x 4x 4x 4x | /**
* Tool-level authorization contract
*
* In OAuth mode every tool declares that it needs an OAuth access token, and a tool call
* that fails because GitLab no longer accepts the account's credentials returns a
* `WWW-Authenticate` challenge in `_meta["mcp/www_authenticate"]`, so the host starts its
* own reconnect flow instead of asking for a token in chat.
*/
import { ErrorCode, McpError, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { loadOAuthConfig } from './config';
import { getTokenContext } from './token-context';
import { defaultResource, resourceMetadataUrl } from './resource';
import { isStructuredToolError, parseGitLabApiError } from '../utils/error-handler';
/** Security scheme every tool declares in OAuth mode. */
export interface OAuthSecurityScheme {
type: 'oauth2';
scopes: string[];
}
/** Every tool is called with an MCP access token carrying `mcp:tools`. */
export const OAUTH_SECURITY_SCHEMES: readonly OAuthSecurityScheme[] = [
{ type: 'oauth2', scopes: ['mcp:tools'] },
];
/** Scope a tool call needs, the one every tool declares. */
const TOOL_SCOPE = OAUTH_SECURITY_SCHEMES[0].scopes[0];
/** Scope a resource list or read needs. */
const RESOURCE_SCOPE = 'mcp:resources';
/** A tool that opens an app panel also needs its panel resource read. */
export const APP_PANEL_SECURITY_SCHEMES: readonly OAuthSecurityScheme[] = [
{ type: 'oauth2', scopes: [TOOL_SCOPE, RESOURCE_SCOPE] },
];
/**
* The error for a resource list or read whose MCP access token lacks `mcp:resources`
* (RFC 6750 section 3.1 insufficient_scope, with its challenge in the error data), or
* undefined when it may proceed. As for tools, calls without an OAuth token context are not
* scope-checked.
*/
export function resourceScopeRejection(): McpError | undefined {
const config = loadOAuthConfig();
const context = getTokenContext();
if (!config || !context?.mcpScopes || context.mcpScopes.includes(RESOURCE_SCOPE)) {
return undefined;
}
const resource = context.resource ?? defaultResource(config.issuer);
const challenge =
`Bearer resource_metadata="${resourceMetadataUrl(resource)}", ` +
`error="insufficient_scope", scope="${RESOURCE_SCOPE}", ` +
`error_description="This access token does not allow resource reads; reconnect to grant ${RESOURCE_SCOPE}"`;
return new McpError(
ErrorCode.InvalidRequest,
`insufficient_scope: the access token was not granted ${RESOURCE_SCOPE}`,
{ 'mcp/www_authenticate': [challenge] },
);
}
/**
* Error result for a tool call whose MCP access token lacks `mcp:tools` (RFC 6750 section
* 3.1 insufficient_scope), or undefined when the call may proceed. The request middleware
* admits any MCP scope, so a token granted only `mcp:resources` is stopped here. Calls
* without an OAuth token context (static token, stdio) are not scope-checked.
*/
export function toolScopeRejection(): CallToolResult | undefined {
const config = loadOAuthConfig();
const context = getTokenContext();
if (!config || !context?.mcpScopes || context.mcpScopes.includes(TOOL_SCOPE)) {
return undefined;
}
const resource = context.resource ?? defaultResource(config.issuer);
const challenge =
`Bearer resource_metadata="${resourceMetadataUrl(resource)}", ` +
`error="insufficient_scope", scope="${TOOL_SCOPE}", ` +
`error_description="This access token does not allow tool calls; reconnect to grant ${TOOL_SCOPE}"`;
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: 'insufficient_scope',
message: `The access token was not granted ${TOOL_SCOPE}`,
}),
},
],
isError: true,
_meta: { 'mcp/www_authenticate': [challenge] },
};
}
/** Bound on the cause chain walked when looking for the GitLab status. */
const MAX_CAUSE_DEPTH = 8;
/** Whether GitLab rejected the account's credentials (HTTP 401) anywhere in the cause chain. */
export function isGitLabAuthFailure(error: unknown): boolean {
let current: unknown = error;
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current instanceof Error; depth++) {
if (isStructuredToolError(current)) {
const status = (current.structuredError as { http_status?: unknown }).http_status;
if (status === 401) return true;
}
if (parseGitLabApiError(current.message)?.status === 401) return true;
current = (current as Error & { cause?: unknown }).cause;
}
return false;
}
/**
* Whether GitLab refused because the token lacks an OAuth scope (HTTP 403 with
* `insufficient_scope`, RFC 6750 section 3.1), as opposed to missing project permissions.
*/
export function isGitLabInsufficientScope(error: unknown): boolean {
let current: unknown = error;
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current instanceof Error; depth++) {
const parsed = parseGitLabApiError(current.message);
if (parsed?.status === 403 && parsed.message.includes('insufficient_scope')) return true;
current = (current as Error & { cause?: unknown }).cause;
}
return false;
}
/**
* Attach a reauthorization challenge to an error result when OAuth is enabled and GitLab
* rejected the account's credentials (401) or their scope (403 insufficient_scope). The
* challenge carries `error` and
* `error_description` (RFC 6750 section 3) and points at the metadata of the resource the
* client called (RFC 9728 section 5.1). Other results are returned unchanged.
*/
export function withReauthChallenge(result: CallToolResult, error: unknown): CallToolResult {
const config = loadOAuthConfig();
if (!config) return result;
let errorParams: string;
if (isGitLabAuthFailure(error)) {
errorParams =
'error="invalid_token", error_description="GitLab no longer accepts this account\'s authorization; reconnect the GitLab account"';
} else if (isGitLabInsufficientScope(error)) {
errorParams =
`error="insufficient_scope", scope="${OAUTH_SECURITY_SCHEMES[0].scopes.join(' ')}", ` +
'error_description="The GitLab authorization lacks a scope this action needs; reconnect the GitLab account to grant it"';
} else {
return result;
}
const resource = getTokenContext()?.resource ?? defaultResource(config.issuer);
const challenge = `Bearer resource_metadata="${resourceMetadataUrl(resource)}", ${errorParams}`;
return {
...result,
isError: true,
_meta: { ...result._meta, 'mcp/www_authenticate': [challenge] },
};
}
|