Skip to content

Conversation

@maccabeelevine
Copy link
Member

@maccabeelevine maccabeelevine commented Dec 5, 2025

Per VUFIND-1809, from https://modelcontextprotocol.io/docs/getting-started/intro

MCP (Model Context Protocol) is an open-source standard for connecting AI applications to external systems.
Using MCP, AI applications like Claude or ChatGPT can connect to data sources (e.g. local files, databases), tools (e.g. search engines, calculators) and workflows (e.g. specialized prompts)—enabling them to access key information and perform tasks.
Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect electronic devices, MCP provides a standardized way to connect AI applications to external systems.

This extends VuFind's existing API capabilities to provide an MCP server, i.e. to respond to MCP queries from AI models.

TODO

  • Configuration of enabled endpoints
  • Expose format / possibly filter on it
  • Combined search
  • Permissions.ini
  • Testing / sandboxing instructions

@maccabeelevine
Copy link
Member Author

This POC doesn't do anything useful yet (check out the amazing addition function), but it is a working MCP server embedded in VuFind. Next step will be to build some real capabilities, i.e. searching and document retrieval.

The implementation adapts the SDK's example of Symfony integration as well as the work @demiankatz is doing on #4672.

There would be some major dependency roadblocks to actually integrating this into VuFind, as I mentioned on the Jira. This POC uses the official PHP SDK, mcp/sdk, which fails to install because it requires "psr/container": "^2.0" while several of our current dependencies are stuck targeting the 1.x releases.

@maccabeelevine
Copy link
Member Author

Note for testing, I've locally modified the vendor file (yes I know) laminas-servicemanager/src/ServiceManager to make the has constructor compatible with psr/container 2.

public function has($name): bool

'vufind_api' => [
'register_controllers' => [
\VuFindApi\Controller\AdminApiController::class,
// \VuFindApi\Controller\McpController::class,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether this actually belongs in VuFindApi, and if so whether it should be discoverable from the main API endpoint, given the protocol is quite different. To consider.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps @EreMaijala will have an opinion on this.

@maccabeelevine
Copy link
Member Author

You can test the server in a couple of ways:

  • The official MCP Inspector tool
  • Via VS Code's chat panel, after adding the MCP server via MCP: Add server

either way, hitting a URL like http://localhost:[port]/vufind/api/v1/mcp

@maccabeelevine
Copy link
Member Author

This POC doesn't do anything useful yet (check out the amazing addition function), but it is a working MCP server embedded in VuFind. Next step will be to build some real capabilities, i.e. searching and document retrieval.

It now does a basic keyword search, and record retrieval by ID.

@maccabeelevine
Copy link
Member Author

There would be some major dependency roadblocks to actually integrating this into VuFind, as I mentioned on the Jira. This POC uses the official PHP SDK, mcp/sdk, which fails to install because it requires "psr/container": "^2.0" while several of our current dependencies are stuck targeting the 1.x releases.

To get around this, I've temporarily forked php-sdk to allow it to use "psr/container": "^1.1.2 || ^2.0". This at least resolves all of the build errors, while we work on getting rid of all the old laminas dependencies that are stuck using ^1.0.0.

Comment on lines +297 to +300
recordPageFullUrl:
vufind.method: "Formatter::getRecordPageFullUrl"
description: Link to the record page from external sites with a fully qualified URL
type: string
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will extract this to a separate PR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*
* @return array The record
*/
#[McpResourceTemplate(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm conflicted about keeping these attributes. In a simple MCP server they are a great, quick way to define its capabilities. But they don't support variable data, or i18n, or dynamic enabling, so in practice they need to be paired with the yaml configuration. As a counter-example I have tools currently defined entirely via yaml, but that leads to bloated configuration (the json schema is pretty verbose) that I don't love either.

I am leaning to just consistently use the yaml config for every capability, verbose but consistent. but open to other thoughts.

}

// $content = json_decode($this->params()->getController()->getRequest()->getContent(), true);
// $mcpMethod = $content['method'] ?? '';
Copy link
Member Author

@maccabeelevine maccabeelevine Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to think through if this is useful. Methods in MCP are not specific enough, i.e. all tools are method "tools/call" and you need a further param "name" to know what's actually being called. But if we go down the route of allowing selective methods/names, then I also have to consider the fact that "tools/list" would list them all. Etc. for resources, prompts, etc. It's probably a good start just to indivisibly allow or disallow MCP. Of course the permissions.ini still lets you narrow down that single permission by IP address, etc.

protected function outputAuthError(string $messageId): Response
{
$error = new Error($messageId, $this->AUTH_ERROR, 'Access denied');
$response = $this->getResponse();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much of this is borrowed from ApiTrait. Not sure if it's worth refactoring that method for the few bits that are useful here.

@maccabeelevine
Copy link
Member Author

To get around this, I've temporarily forked php-sdk to allow it to use "psr/container": "^1.1.2 || ^2.0". This at least resolves all of the build errors, while we work on getting rid of all the old laminas dependencies that are stuck using ^1.0.0.

Forking is no longer necessary, as the upstream php-sdk now supports earlier psr/container.

Copy link
Member

@demiankatz demiankatz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @maccabeelevine, I took a first look through this. I'm not yet familiar with the libraries and technologies being used, so I mainly focused on general design and documentation for this initial pass.

General:
# Enable the MCP Server and register all capabilities defined below. Disabled by default.
# Access must also be granted in permissions.ini.
# enabled: true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to have either enabled: false or #enabled: true here? Usually we only put a comment-space before a descriptive comment, and a comment-no-space before a disabled setting... but with YAML being so finicky about whitespace, anything we can do to reduce the chances of the user messing up indentation will be helpful!

(And whatever pattern we decide on here we should probably try to apply to other commented-out stuff below).


General:
# Enable the MCP Server and register all capabilities defined below. Disabled by default.
# Access must also be granted in permissions.ini.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth mentioning the specific access.mcp permission here while we're at it? Or is this intentionally vague for flexibility?

'vufind_api' => [
'register_controllers' => [
\VuFindApi\Controller\AdminApiController::class,
// \VuFindApi\Controller\McpController::class,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps @EreMaijala will have an opinion on this.

"process-timeout": 0,
"allow-plugins": {
"composer/package-versions-deprecated": true,
"php-http/discovery": true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed? If so, how is it used?

}

$builder = Server::builder()
->setServerInfo(name: 'VuFind Server', version: '0.0.1', description: 'The library catalog')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should add the ® symbol here. Might also be nice to replace the description with the title value from config.ini.

Suggested change
->setServerInfo(name: 'VuFind Server', version: '0.0.1', description: 'The library catalog')
->setServerInfo(name: 'VuFind® Server', version: '0.0.1', description: 'The library catalog')

*
* @return string
*/
abstract protected function getSearchClassId();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might as well add return types when defining new functions...

Suggested change
abstract protected function getSearchClassId();
abstract protected function getSearchClassId(): string;

*
* @return string
*/
protected function getRequestParam()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
protected function getRequestParam()
protected function getRequestParam(): string

*
* @return string
*/
protected function getSearchClassId()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
protected function getSearchClassId()
protected function getSearchClassId(): string

public function searchRecords(string $keywords, ?string $contentType = null): array
{
$limit = $this->limit;
$rawRequest = [$this->getRequestParam() => urldecode($keywords)];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really expect $keywords to come in url-encoded? Is that normal?

*
* @return string
*/
protected function getRecordPageFullUrl($record)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to be sure to reconcile this with #4954 when that is done.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants