php-sdk/src/Server/Stateless/StatelessProtocol.php
Christopher Hertel daac88d626
[Server] Make subscriptions/listen actually deliver (#451)
The stream acknowledged, slept thirty seconds and closed, so tools and
resource changes never reached a client — and with resources/subscribe
gone, that left the revision with no server-push at all. A notification bus
carries them: in-memory where publisher and stream share a process, PSR-16
where php-fpm puts them in different ones. The conformance server proves
the latter — a tools/call in one worker reaches a stream held in another.
2026-08-19 00:08:02 +02:00

766 lines
30 KiB
PHP

<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Server\Stateless;
use Mcp\Exception\InvalidInputMessageException;
use Mcp\Exception\LogicException;
use Mcp\Exception\MissingRequestMetaException;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Exception\RequestStateException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Result\DiscoverResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\Configuration;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Protocol;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\Session;
use Mcp\Server\Subscription\NotificationBusInterface;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Server\Wire\Rev2026Codec;
use Mcp\Server\Wire\WireCodecInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Dispatches a single modern-era (SEP-2575) request.
*
* Separate from {@see Protocol} because the modern era has no
* session to resolve, replay or keep a fiber against; the two eras share
* request handlers, not control flow.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class StatelessProtocol
{
private readonly WireCodecInterface $codec;
/**
* Methods the modern era deleted. Answered as unknown methods, which is
* what they are to a modern server.
*
* A deny-list rather than an allow-list on purpose: extensions add methods
* this class has never heard of, so an unlisted method has to reach
* dispatch. Every removal named in the 2026-07-28 changelog belongs here —
* the handlers behind them stay registered for the handshake era, which is
* why the era guard, and not the registration, is what turns them off.
*/
public const REMOVED_METHODS = [
'initialize',
'notifications/initialized',
'ping',
'logging/setLevel',
// Replaced by the `resourceSubscriptions` filter of subscriptions/listen.
'resources/subscribe',
'resources/unsubscribe',
'notifications/roots/list_changed',
];
public const DISCOVER_METHOD = 'server/discover';
public const LISTEN_METHOD = 'subscriptions/listen';
public const ACKNOWLEDGED_NOTIFICATION = 'notifications/subscriptions/acknowledged';
/**
* What a client is told when a handler fails in a way nothing anticipated.
*
* Deliberately generic rather than {@see \Throwable::getMessage()}: the
* real message is logged, not returned, so an internal detail (a
* connection string, a file path, another library's error text) never
* reaches the client that triggered it.
*/
private const INTERNAL_ERROR_MESSAGE = 'Internal server error.';
/**
* @param iterable<RequestHandlerInterface<ResultInterface>> $requestHandlers
* @param list<ProtocolVersion> $supportedVersions
*/
public function __construct(
private readonly iterable $requestHandlers,
private readonly MessageFactory $messageFactory,
private readonly Configuration $configuration,
private readonly array $supportedVersions = [ProtocolVersion::V2026_07_28],
private readonly LoggerInterface $logger = new NullLogger(),
private readonly float $subscriptionLifetime = 30.0,
?WireCodecInterface $codec = null,
private readonly ?StandardHeaderValidator $headerValidator = null,
private readonly ?RequestStateCodec $requestStateCodec = null,
?CachePolicy $cachePolicy = null,
private readonly ?NotificationBusInterface $notificationBus = null,
) {
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy);
if (null === $this->headerValidator) {
// Not fatal: a transport without a header layer — stdio — has
// nothing to validate. But on HTTP the headers are REQUIRED for
// compliance, so an absent validator there is a silently
// non-conformant server and worth saying out loud once.
$this->logger->warning('No StandardHeaderValidator configured; the SEP-2243 request headers will not be enforced. This is correct only for a transport without a header layer.');
}
}
/**
* Whether the transport carrying this dispatcher has a header layer whose
* required members must be present.
*
* The validator's presence is the signal: it is what a header-bearing
* transport installs, and stdio carries its metadata inline instead
* (see the stdio binding's "Request Metadata").
*/
private function requiresTransportHeaders(): bool
{
return null !== $this->headerValidator;
}
/**
* Answers one JSON-RPC request read from an HTTP request body.
*
* @param array<string, string> $headers request headers, case-insensitively matched
*/
public function handle(string $body, array $headers = []): StatelessResult
{
try {
/** @var array<string, mixed>|null $decoded */
$decoded = json_decode($body, true, flags: \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
return StatelessResult::error(Error::forParseError($e->getMessage()), 400);
}
if (!\is_array($decoded)) {
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must be a JSON object.'), 400);
}
// The id is read before anything is validated so that every error below
// can echo it when there is one to echo. A missing or malformed id is
// left as null rather than coerced to "" — Error::jsonSerialize()
// already knows to omit a null id instead of fabricating one, and
// "id": "" would falsely claim the sender issued a request with an
// empty-string id.
//
// An absent id and an unreadable one are different messages: the first
// is a notification, the second a malformed request. JSON-RPC 2.0
// writes "no id" as an explicit null, so that counts as absent too.
$isNotification = !\array_key_exists('id', $decoded) || null === $decoded['id'];
$id = $decoded['id'] ?? null;
if (!\is_string($id) && !\is_int($id)) {
$id = null;
}
$method = $decoded['method'] ?? null;
if (!\is_string($method) || '' === $method) {
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must carry a "method".', $id), 400);
}
$params = \is_array($decoded['params'] ?? null) ? $decoded['params'] : null;
// No id is a notification. It gets an acknowledgment, never a response
// — answering one with a JSON-RPC message would invent a correlation
// the client has no request to match it against. Checked before the
// `_meta` parse: notification params carry `NotificationMetaObject`,
// which has none of a request's required members.
if ($isNotification) {
return $this->acknowledge($method);
}
if (null === $id) {
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400);
}
try {
$meta = RequestMeta::fromParams($params);
} catch (MissingRequestMetaException $e) {
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
}
if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) {
return $versionError;
}
// After the version check: a peer on the wrong revision has a more
// fundamental problem than headers that disagree with its body.
if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) {
return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400);
}
if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) {
if (self::DISCOVER_METHOD === $method) {
return $this->encode($method, $id, $this->discover());
}
return $this->listen($params, $id);
}
if (\in_array($method, self::REMOVED_METHODS, true)) {
return StatelessResult::error(
Error::forMethodNotFound(\sprintf('Method "%s" does not exist in protocol version %s.', $method, $meta->protocolVersion), $id),
404,
);
}
return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers));
}
/**
* Answers a notification.
*
* This revision's core defines no client-to-server notification over HTTP —
* `notifications/cancelled` is stdio-only, since closing the response
* stream is the cancellation signal here — so anything arriving is either
* an extension's or a client still speaking an older revision. Accepting
* the former and refusing the latter both come out as a status with no
* body; what must not happen is a JSON-RPC response.
*/
private function acknowledge(string $method): StatelessResult
{
if (\in_array($method, self::REMOVED_METHODS, true)) {
$this->logger->debug('Refused a notification this revision removed.', ['method' => $method]);
return StatelessResult::empty(400);
}
$this->logger->debug('Accepted a notification with no handler to run.', ['method' => $method]);
return StatelessResult::empty(202);
}
/**
* Header and `_meta` must agree before the version can be judged supported:
* when they disagree the server cannot know which the client meant, so a
* mismatch outranks an unsupported version.
*
* @param array<string, string> $headers
*/
private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id): ?StatelessResult
{
$headerVersion = $this->header($headers, 'MCP-Protocol-Version');
// REQUIRED on every POST. The 2025-03-26 fallback for a header-less
// request exists only for servers choosing to serve pre-2025-06-18
// clients, which a modern-only endpoint is not.
if (null === $headerVersion && $this->requiresTransportHeaders()) {
return StatelessResult::error(
Error::forHeaderMismatch(
\sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion),
$id,
),
400,
);
}
if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) {
return StatelessResult::error(
Error::forHeaderMismatch(
\sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $meta->protocolVersion),
$id,
),
400,
);
}
$version = ProtocolVersion::tryFrom($meta->protocolVersion);
if (null === $version || !\in_array($version, $this->supportedVersions, true)) {
return StatelessResult::error(
Error::forUnsupportedProtocolVersion($meta->protocolVersion, $this->supportedVersions, $id),
400,
);
}
return null;
}
/**
* Opens a `subscriptions/listen` stream. The subscription id is the
* JSON-RPC id of this request, so there is none to mint.
*
* @param array<string, mixed>|null $params
*/
private function listen(?array $params, string|int $id): StatelessResult
{
$notifications = \is_array($params['notifications'] ?? null) ? $params['notifications'] : null;
$agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities);
$lifetime = $this->subscriptionLifetime;
$bus = $this->notificationBus;
$codec = $this->codec;
return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec): \Generator {
// MUST be the first message carrying this subscription's id, and
// MUST precede any notification on it.
yield [
'jsonrpc' => '2.0',
'method' => self::ACKNOWLEDGED_NOTIFICATION,
'params' => [
'_meta' => [RequestMeta::SUBSCRIPTION_ID => $id],
'notifications' => (object) $agreed->toAcknowledgedArray(),
],
];
// From now, not from the beginning: a subscriber wants what happens
// next, not a replay of the server's history.
$cursor = $bus?->cursor() ?? 0;
// The tick is not optional: PHP spots a dropped peer by writing,
// and a sleeping loop would pin an FPM worker for the full lifetime.
$deadline = 0.0 >= $lifetime ? \INF : microtime(true) + $lifetime;
while (microtime(true) < $deadline) {
if (null !== $bus) {
[$notifications, $cursor] = $bus->since($cursor);
foreach ($notifications as $notification) {
if (!$agreed->carries($notification)) {
continue;
}
yield self::tagWithSubscription($notification, $id);
}
}
yield null;
if (connection_aborted()) {
return;
}
usleep(250_000);
}
// Graceful closure (SHOULD), so the client can tell this from a
// dropped transport.
yield [
'jsonrpc' => '2.0',
'id' => $id,
'result' => $codec->encodeResult(self::LISTEN_METHOD, [
'resultType' => 'complete',
'_meta' => [RequestMeta::SUBSCRIPTION_ID => $id],
], false),
];
});
}
/**
* Every message on a listen stream carries the id of the subscription it
* belongs to, which is how a client demultiplexes them on stdio — where
* they all share one channel.
*
* @return array<string, mixed>
*/
private static function tagWithSubscription(Notification $notification, string|int $id): array
{
/** @var array<string, mixed> $frame */
$frame = $notification->jsonSerialize();
$params = \is_array($frame['params'] ?? null) ? $frame['params'] : [];
$meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$meta[RequestMeta::SUBSCRIPTION_ID] = $id;
$params['_meta'] = $meta;
$frame['params'] = $params;
return $frame;
}
private function discover(): DiscoverResult
{
return new DiscoverResult(
$this->supportedVersions,
$this->configuration->capabilities,
$this->configuration->instructions,
);
}
/**
* @param array<string, mixed> $decoded
*/
private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id, bool $wantsStream = false): StatelessResult
{
try {
$messages = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR));
} catch (\Throwable $e) {
$this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]);
return StatelessResult::error(Error::forMethodNotFound(\sprintf('Method "%s" is not supported.', $method), $id), 404);
}
$request = $messages[0] ?? null;
// The factory hands back an exception object rather than throwing one,
// distinguishing a genuinely unknown method (-32601, the client should
// stop asking) from a known method the message could not otherwise be
// parsed into (-32600, the request itself is malformed).
if ($request instanceof InvalidInputMessageException) {
$unknownMethod = \sprintf('Unknown method "%s".', $method) === $request->getMessage();
return StatelessResult::error(
$unknownMethod
? Error::forMethodNotFound($request->getMessage(), $id)
: Error::forInvalidRequest($request->getMessage(), $id),
$unknownMethod ? 404 : 400,
);
}
if (!$request instanceof Request) {
// Reachable only for a well-formed Response/Error object posted to
// this endpoint — decodable by the factory, but not a request this
// server can answer.
return StatelessResult::error(Error::forInvalidRequest(\sprintf('"%s" is not a request this server can answer.', $method), $id), 400);
}
// Request::fromArray() already rejected a missing/invalid id (as an
// InvalidInputMessageException, handled above), so this request's id
// is always present here — reusing it narrows $id for the rest of this
// method instead of trusting the raw, still-nullable value from above.
$id = $request->getId();
$session = new Session(new InMemorySessionStore());
$session->set(RequestMeta::class, $meta);
// Under the same keys the handshake era writes, so everything reading
// connection state — ClientGateway's capability probes above all — sees
// this request's declaration instead of an empty session.
$session->set('client_capabilities', $meta->clientCapabilities->jsonSerialize());
$session->set('protocol_version', $meta->protocolVersion);
try {
$input = $this->liftInputContext($decoded['params'] ?? null);
} catch (RequestStateException $e) {
// Invalid params, not an authorization failure: the client only
// echoes what it was given, and the reason stays out of the answer.
$this->logger->warning('Rejected a requestState that failed verification.', ['method' => $method, 'reason' => $e->getMessage()]);
return StatelessResult::error(Error::forInvalidParams('The supplied requestState failed verification.', $id), 400);
}
if (null !== $input) {
$session->set(InputContext::class, $input);
}
if (null !== $this->requestStateCodec) {
$session->set(RequestStateCodec::class, $this->requestStateCodec);
}
// What ClientGateway::progress() reads to find the progress token, and
// the handshake era sets under the same key.
$session->set(Protocol::SESSION_ACTIVE_REQUEST_META, $request->getMeta());
foreach ($this->requestHandlers as $handler) {
if (!$handler->supports($request)) {
continue;
}
$run = $this->run($handler, $request, $session, $meta);
try {
// Runs the handler up to its first notification, or to the end
// if it emits none. Deciding here and not earlier is what keeps
// the status codes honest: a request that turns out to need
// -32021 has said nothing yet, so it can still be answered
// with 400 rather than an error frame under a 200.
$run->rewind();
} catch (\Throwable $e) {
return $this->toErrorResult($method, $id, $e);
}
if ($run->valid() && $wantsStream) {
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $meta, $method, $id, null === $input));
}
try {
// Stepped rather than foreach()ed: rewind() already advanced it,
// and a generator will not be traversed a second time.
while ($run->valid()) {
$this->logger->debug('Dropped a notification: the client did not accept a response stream.', [
'method' => $method,
'notification' => $run->current()::getMethod(),
]);
$run->next();
}
$result = $run->getReturn();
} catch (\Throwable $e) {
return $this->toErrorResult($method, $id, $e);
}
if ($result instanceof Error) {
return StatelessResult::error($result, 400);
}
if (null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
return $capabilityError;
}
return $this->encode($method, $id, $result->result, null === $input);
}
return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404);
}
/**
* Runs a handler, yielding the notifications it emits as it emits them and
* returning its result.
*
* The fiber is what makes a handler's `$gateway->progress(...)` look
* synchronous while the caller decides where the notification goes. Server
* -to-client *requests* are refused rather than forwarded: this revision
* carries those in the result (MRTR), and putting one on a response stream
* is something the transport binding forbids outright.
*
* @param RequestHandlerInterface<ResultInterface> $handler
*
* @return \Generator<int, Notification, null, Response<ResultInterface>|Error>
*/
private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator
{
$fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session));
$suspended = $fiber->start();
while (!$fiber->isTerminated()) {
$notification = $this->readNotification($suspended, $meta);
if (null !== $notification) {
yield $notification;
}
$suspended = $fiber->resume(null);
}
/** @var Response<ResultInterface>|Error $return */
$return = $fiber->getReturn();
return $return;
}
/**
* Reads one fiber suspension, or null when it carries nothing to send.
*
* @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with
*/
private function readNotification(mixed $suspended, RequestMeta $meta): ?Notification
{
if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) {
if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) {
throw new LogicException('This protocol revision has no server-initiated requests: return an InputRequiredResult naming what you need instead, and read the answers back through RequestContext::getInputContext(). See the multi round-trip requests pattern.');
}
return null;
}
$notification = $suspended['notification'] ?? null;
if (!$notification instanceof Notification) {
return null;
}
// The client opts into logs per request; with no level named the server
// MUST NOT send any, which is why an absent level drops rather than
// defaults.
if ($notification instanceof LoggingMessageNotification) {
if (null === $meta->logLevel || !$notification->level->isAtLeast($meta->logLevel)) {
return null;
}
}
return $notification;
}
/**
* The frames of a request-scoped response stream: the notifications the
* handler emits, then the response that ends it.
*
* @param \Generator<int, Notification, null, Response<ResultInterface>|Error> $run
*
* @return \Generator<mixed>
*/
private function streamFrames(\Generator $run, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator
{
try {
while ($run->valid()) {
yield $run->current()->jsonSerialize();
$run->next();
}
$result = $run->getReturn();
} catch (\Throwable $e) {
// Headers left long ago, so the status is already 200 and the only
// way left to report this is a frame.
yield $this->toErrorResult($method, $id, $e)->message?->jsonSerialize();
return;
}
if (!$result instanceof Error && null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
yield $capabilityError->message?->jsonSerialize();
return;
}
yield $result instanceof Error
? $result->jsonSerialize()
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize(), $cacheable)];
}
/**
* Refuses to send an ask the client cannot answer.
*
* The handler's mistake rather than the client's, but the client is the one
* that has to hear about it, and `-32021` is precisely the code for
* "processing this needs a capability you did not declare" — so it is
* reported as that, and logged as the server-side bug it is.
*/
private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?StatelessResult
{
if (!$result instanceof InputRequiredResult) {
return null;
}
$missing = InputRequestCapabilities::missing($result, $meta->clientCapabilities);
if (null === $missing) {
return null;
}
$this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [
'method' => $method,
'required' => $missing->jsonSerialize(),
]);
return StatelessResult::error(
Error::forMissingRequiredClientCapability(
'The server needs input this client did not declare it can provide.',
$missing,
$id,
),
400,
);
}
/**
* The one place a handler's exception becomes an answer, so the streaming
* and non-streaming paths cannot disagree about which code it earns.
*/
private function toErrorResult(string $method, string|int $id, \Throwable $e): StatelessResult
{
if ($e instanceof MissingRequiredClientCapabilityException) {
return StatelessResult::error(
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
400,
);
}
if ($e instanceof \InvalidArgumentException) {
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
}
if ($e instanceof LogicException) {
// Guidance for the tool author, not a detail leaked from their
// code or a dependency's — safe to echo back verbatim.
return StatelessResult::error(Error::forInternalError($e->getMessage(), $id), 500);
}
$this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]);
return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500);
}
/**
* Reads the multi round-trip material off a retry, verifying the state
* before any of it reaches a handler. Neither member means a first call,
* which is what a handler tests to decide whether it still needs to ask.
*
* @param array<string, mixed>|null $params
*
* @throws RequestStateException when a state is present but does not verify
*/
private function liftInputContext(?array $params): ?InputContext
{
$responses = \is_array($params['inputResponses'] ?? null) ? $params['inputResponses'] : null;
$state = \is_string($params['requestState'] ?? null) ? $params['requestState'] : null;
if (null === $responses && null === $state) {
return null;
}
// Answers are result objects; dropping non-objects leaves the handler
// to ask again rather than read a malformed retry as satisfied.
if (null !== $responses) {
$responses = array_filter($responses, static fn (mixed $response): bool => \is_array($response));
}
$payload = [];
if (null !== $state) {
// A server with no codec never minted a state, so this one cannot
// have come from here.
if (null === $this->requestStateCodec) {
throw new RequestStateException('mac');
}
$payload = $this->requestStateCodec->verify($state);
}
return new InputContext($responses ?? [], $payload);
}
/**
* Runs a result through the wire codec. Passed as-is rather than via a
* json round trip, which would turn a nested `{}` into `[]`.
*/
private function encode(string $method, string|int $id, ResultInterface $result, bool $cacheable = true): StatelessResult
{
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize(), $cacheable));
}
/**
* Whether the client will read a response stream.
*
* Clients MUST offer both content types, so this is normally true; a client
* that does not gets its notifications dropped rather than a stream it
* cannot parse.
*
* @param array<string, string> $headers
*/
private static function acceptsEventStream(array $headers): bool
{
foreach ($headers as $key => $value) {
if (0 === strcasecmp($key, 'Accept')) {
return str_contains(strtolower($value), 'text/event-stream');
}
}
return false;
}
/**
* @param array<string, string> $headers
*/
private function header(array $headers, string $name): ?string
{
foreach ($headers as $key => $value) {
if (0 === strcasecmp($key, $name)) {
return $value;
}
}
return null;
}
}