Add MCP identity mapping and read tools (#111, phase 4)
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
MCP requests now run as the signed-in Tinker Tickets user, under exactly
the same rules as the web login, and expose the first two tools:
search_tickets and get_ticket.
Identity:
- IdentityMiddleware maps the validated token to a user with the same
checks as AuthMiddleware: the admin/employee group rule, now extracted
into helpers/AccessPolicy.php so both entry points share one copy, then
UserModel::syncUserFromAuthelia(), which creates/updates the row and
derives is_admin from groups.
- Claims are read from the validated token's server-side PSR-7 request
attributes, not from JSON-RPC _meta. The SDK's OAuthRequestMetaMiddleware
is deliberately not used: it array_merges into client-writable _meta,
so only the keys the validator happens to set are overwritten and a
client could inject others.
Scopes (ToolScopeMiddleware), enforced before dispatch:
- lifecycle messages need only a valid token; write tools (listed in
ToolCatalog, the single registry) need tickets:write; everything else
needs tickets:read, which tickets:write implies.
- Denials are the spec's step-up challenge: 403 +
WWW-Authenticate: Bearer error="insufficient_scope", scope=...,
resource_metadata=...
Tools (read-only, annotated readOnlyHint):
- search_tickets: text/status/priority/category/assignee ("me",
"unassigned", or a username), paginated, via TicketModel::getAllTickets
with the user's visibility filter. Defaults to every non-Closed status.
- get_ticket: details + comments, gated by canUserAccessTicket. A missing
ticket and a non-visible one return the same "not found".
Verified locally against a real MariaDB fixture (public, confidential,
internal+group, and closed tickets across two users), driving the real
pipeline (ToolCatalog, both middlewares, SDK transport) with only JWT
validation stubbed: 20/20 checks pass, including visibility parity per
user, confidential tickets hidden from non-owners, the group check
rejecting a user without admin/employee, a missing preferred_username
rejected, 403 insufficient_scope for a token without tickets:*, and
write implying read. Also exercised the stateless 2026-07-28 era (no
session, _meta + MCP-Protocol-Version/Mcp-Method/Mcp-Name headers), which
returns the same visibility-filtered results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
+26
-7
@@ -17,6 +17,12 @@ RateLimitMiddleware::apply('api', false);
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
|
||||
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||
use Mcp\Server;
|
||||
@@ -24,7 +30,6 @@ use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
|
||||
@@ -35,6 +40,9 @@ use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use TinkerTickets\Mcp\Auth\IdentityMiddleware;
|
||||
use TinkerTickets\Mcp\Auth\ToolScopeMiddleware;
|
||||
use TinkerTickets\Mcp\ToolCatalog;
|
||||
|
||||
$resourceUrl = $GLOBALS['config']['MCP_RESOURCE_URL'] ?? null;
|
||||
$issuer = $GLOBALS['config']['MCP_OAUTH_ISSUER'] ?? null;
|
||||
@@ -99,11 +107,15 @@ $metadata = new ProtectedResourceMetadata(
|
||||
])),
|
||||
);
|
||||
|
||||
$server = Server::builder()
|
||||
->setServerInfo('Tinker Tickets', '1.0.0')
|
||||
// Handshake-era clients (pre-2026-07-28) still use protocol sessions.
|
||||
->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions'))
|
||||
->build();
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$server = ToolCatalog::register(
|
||||
Server::builder()
|
||||
->setServerInfo('Tinker Tickets', '1.0.0')
|
||||
// Handshake-era clients (pre-2026-07-28) still use protocol sessions.
|
||||
->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')),
|
||||
$conn
|
||||
)->build();
|
||||
|
||||
$transport = new StreamableHttpTransport(
|
||||
$request,
|
||||
@@ -115,7 +127,14 @@ $transport = new StreamableHttpTransport(
|
||||
new DnsRebindingProtectionMiddleware([$canonical['host']]),
|
||||
new ProtectedResourceMetadataMiddleware($metadata),
|
||||
new AuthorizationMiddleware($validator, $metadata),
|
||||
new OAuthRequestMetaMiddleware(),
|
||||
// Identity comes from the validated token's server-side request
|
||||
// attributes, never from client-writable JSON-RPC `_meta` (so the
|
||||
// SDK's OAuthRequestMetaMiddleware is intentionally not used).
|
||||
new IdentityMiddleware($conn, $psr17, $psr17),
|
||||
new ToolScopeMiddleware(
|
||||
$psr17,
|
||||
$canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user