mirror of
https://github.com/navidrome/navidrome.git
synced 2025-05-05 21:01:08 +03:00
fix: update MCP server path for agent initialization
Change the MCP server path in MCPAgent to point to the correct relative directory for the WASM file. This adjustment ensures proper initialization and access to the server resources, aligning with recent enhancements in the MCPAgent's handling of server types.
This commit is contained in:
parent
73da7550d6
commit
2f71516dde
2
core/agents/mcp/mcp-server/.gitignore
vendored
Normal file
2
core/agents/mcp/mcp-server/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
mcp-server
|
||||
*.wasm
|
17
core/agents/mcp/mcp-server/README.md
Normal file
17
core/agents/mcp/mcp-server/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# MCP Server (Proof of Concept)
|
||||
|
||||
This directory contains the source code for the `mcp-server`, a simple server implementation used as a proof-of-concept (PoC) for the Navidrome Plugin/MCP agent system.
|
||||
|
||||
This server is designed to be compiled into a WebAssembly (WASM) module (`.wasm`) using the `wasip1` target.
|
||||
|
||||
## Compilation
|
||||
|
||||
To compile the server into a WASM module (`mcp-server.wasm`), navigate to this directory in your terminal and run the following command:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -o mcp-server.wasm .
|
||||
```
|
||||
|
||||
**Note:** This command compiles the WASM module _without_ the `netgo` tag. Networking operations (like HTTP requests) are expected to be handled by host functions provided by the embedding application (Navidrome's `MCPAgent`) rather than directly within the WASM module itself.
|
||||
|
||||
Place the resulting `mcp-server.wasm` file where the Navidrome `MCPAgent` expects it (currently configured via the `McpServerPath` constant in `core/agents/mcp/mcp_agent.go`).
|
151
core/agents/mcp/mcp-server/dbpedia.go
Normal file
151
core/agents/mcp/mcp-server/dbpedia.go
Normal file
@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json" // Reusing ErrNotFound from wikidata.go (implicitly via main)
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dbpediaEndpoint = "https://dbpedia.org/sparql"
|
||||
|
||||
// Default timeout for DBpedia requests
|
||||
const defaultDbpediaTimeout = 20 * time.Second
|
||||
|
||||
// Can potentially reuse SparqlResult, SparqlBindings, SparqlValue from wikidata.go
|
||||
// if the structure is identical. Assuming it is for now.
|
||||
|
||||
// GetArtistBioFromDBpedia queries DBpedia for an artist's abstract using their name.
|
||||
func GetArtistBioFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("name is required to query DBpedia by name")
|
||||
}
|
||||
|
||||
// Escape name for SPARQL query literal
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
|
||||
// SPARQL query using DBpedia ontology (dbo)
|
||||
// Prefixes are recommended but can be omitted if endpoint resolves them.
|
||||
// Searching case-insensitively on the label.
|
||||
// Filtering for dbo:MusicalArtist or dbo:Band.
|
||||
// Selecting the English abstract.
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
PREFIX dbo: <http://dbpedia.org/ontology/>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
|
||||
SELECT DISTINCT ?abstract WHERE {
|
||||
?artist rdfs:label ?nameLabel .
|
||||
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
|
||||
|
||||
# Ensure it's a musical artist or band
|
||||
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
|
||||
|
||||
?artist dbo:abstract ?abstract .
|
||||
FILTER(LANG(?abstract) = "en")
|
||||
} LIMIT 1`, escapedName)
|
||||
|
||||
// Prepare and execute HTTP request
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "application/sparql-results+json") // DBpedia standard format
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
|
||||
|
||||
timeout := defaultDbpediaTimeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute DBpedia request: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("DBpedia query failed with status %d: %s", statusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result SparqlResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
// Try reading the raw body for debugging if JSON parsing fails
|
||||
// (Seek back to the beginning might be needed if already read for error)
|
||||
// For simplicity, just return the parsing error now.
|
||||
return "", fmt.Errorf("failed to decode DBpedia response: %w", err)
|
||||
}
|
||||
|
||||
// Extract the abstract
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if abstractVal, ok := result.Results.Bindings[0]["abstract"]; ok {
|
||||
return abstractVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Use the shared ErrNotFound
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// GetArtistWikipediaURLFromDBpedia queries DBpedia for an artist's Wikipedia URL using their name.
|
||||
func GetArtistWikipediaURLFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("name is required to query DBpedia by name for URL")
|
||||
}
|
||||
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
|
||||
// SPARQL query using foaf:isPrimaryTopicOf
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
PREFIX dbo: <http://dbpedia.org/ontology/>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
|
||||
|
||||
SELECT DISTINCT ?wikiPage WHERE {
|
||||
?artist rdfs:label ?nameLabel .
|
||||
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
|
||||
|
||||
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
|
||||
|
||||
?artist foaf:isPrimaryTopicOf ?wikiPage .
|
||||
# Ensure it links to the English Wikipedia
|
||||
FILTER(STRSTARTS(STR(?wikiPage), "https://en.wikipedia.org/"))
|
||||
} LIMIT 1`, escapedName)
|
||||
|
||||
// Prepare and execute HTTP request (similar structure to bio query)
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "application/sparql-results+json")
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
|
||||
|
||||
timeout := defaultDbpediaTimeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute DBpedia URL request: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("DBpedia URL query failed with status %d: %s", statusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result SparqlResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode DBpedia URL response: %w", err)
|
||||
}
|
||||
|
||||
// Extract the URL
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if pageVal, ok := result.Results.Bindings[0]["wikiPage"]; ok {
|
||||
return pageVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNotFound
|
||||
}
|
16
core/agents/mcp/mcp-server/fetch.go
Normal file
16
core/agents/mcp/mcp-server/fetch.go
Normal file
@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fetcher defines an interface for making HTTP requests, abstracting
|
||||
// over native net/http and WASM host functions.
|
||||
type Fetcher interface {
|
||||
// Fetch performs an HTTP request.
|
||||
// Returns the status code, response body, and any error encountered.
|
||||
// Note: Implementations should aim to return the body even on non-2xx status codes
|
||||
// if the body was successfully read, allowing callers to potentially inspect it.
|
||||
Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error)
|
||||
}
|
71
core/agents/mcp/mcp-server/fetch_native.go
Normal file
71
core/agents/mcp/mcp-server/fetch_native.go
Normal file
@ -0,0 +1,71 @@
|
||||
//go:build !wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type nativeFetcher struct {
|
||||
// We could hold a shared client, but creating one per request
|
||||
// with the specific timeout is simpler for this adapter.
|
||||
}
|
||||
|
||||
// Ensure nativeFetcher implements Fetcher
|
||||
var _ Fetcher = (*nativeFetcher)(nil)
|
||||
|
||||
// NewFetcher creates the default native HTTP fetcher.
|
||||
func NewFetcher() Fetcher {
|
||||
return &nativeFetcher{}
|
||||
}
|
||||
|
||||
func (nf *nativeFetcher) Fetch(ctx context.Context, method, urlStr string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
|
||||
// Create a client with the specific timeout for this request
|
||||
client := &http.Client{Timeout: timeout}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if requestBody != nil {
|
||||
bodyReader = bytes.NewReader(requestBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to create native request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers consistent with previous direct client usage
|
||||
req.Header.Set("Accept", "application/sparql-results+json, application/json")
|
||||
// Note: Specific User-Agent was set per call site previously, might need adjustment
|
||||
// if different user agents are desired per service.
|
||||
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (Native Client)")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// Let context cancellation errors pass through
|
||||
if ctx.Err() != nil {
|
||||
return 0, nil, ctx.Err()
|
||||
}
|
||||
return 0, nil, fmt.Errorf("native HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
statusCode = resp.StatusCode
|
||||
responseBodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
// Still return status code if body read fails
|
||||
return statusCode, nil, fmt.Errorf("failed to read native response body: %w", readErr)
|
||||
}
|
||||
responseBody = responseBodyBytes
|
||||
|
||||
// Mimic behavior of returning body even on error status
|
||||
if statusCode < 200 || statusCode >= 300 {
|
||||
return statusCode, responseBody, fmt.Errorf("native request failed with status %d", statusCode)
|
||||
}
|
||||
|
||||
return statusCode, responseBody, nil
|
||||
}
|
160
core/agents/mcp/mcp-server/fetch_wasm.go
Normal file
160
core/agents/mcp/mcp-server/fetch_wasm.go
Normal file
@ -0,0 +1,160 @@
|
||||
//go:build wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// --- WASM Host Function Import --- (Copied from user prompt)
|
||||
|
||||
//go:wasmimport env http_fetch
|
||||
//go:noescape
|
||||
func http_fetch(
|
||||
// Request details
|
||||
urlPtr, urlLen uint32,
|
||||
methodPtr, methodLen uint32,
|
||||
bodyPtr, bodyLen uint32,
|
||||
timeoutMillis uint32,
|
||||
// Result pointers
|
||||
resultStatusPtr uint32,
|
||||
resultBodyPtr uint32, resultBodyCapacity uint32, resultBodyLenPtr uint32,
|
||||
resultErrorPtr uint32, resultErrorCapacity uint32, resultErrorLenPtr uint32,
|
||||
) uint32 // 0 on success, 1 on host error
|
||||
|
||||
// --- Go Wrapper for Host Function --- (Copied from user prompt)
|
||||
|
||||
const (
|
||||
defaultResponseBodyCapacity = 1024 * 10 // 10 KB for response body
|
||||
defaultResponseErrorCapacity = 1024 // 1 KB for error messages
|
||||
)
|
||||
|
||||
// callHostHTTPFetch provides a Go-friendly interface to the http_fetch host function.
|
||||
func callHostHTTPFetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
|
||||
|
||||
// --- Prepare Input Pointers ---
|
||||
urlPtr, urlLen := stringToPtr(url)
|
||||
methodPtr, methodLen := stringToPtr(method)
|
||||
bodyPtr, bodyLen := bytesToPtr(requestBody)
|
||||
|
||||
timeoutMillis := uint32(timeout.Milliseconds())
|
||||
if timeoutMillis <= 0 {
|
||||
timeoutMillis = 30000 // Default 30 seconds if 0 or negative
|
||||
}
|
||||
if timeout == 0 {
|
||||
// Handle case where context might already be cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepare Output Buffers and Pointers ---
|
||||
resultBodyBuffer := make([]byte, defaultResponseBodyCapacity)
|
||||
resultErrorBuffer := make([]byte, defaultResponseErrorCapacity)
|
||||
|
||||
resultStatus := uint32(0)
|
||||
resultBodyLen := uint32(0)
|
||||
resultErrorLen := uint32(0)
|
||||
|
||||
resultStatusPtr := &resultStatus
|
||||
resultBodyPtr, resultBodyCapacity := bytesToPtr(resultBodyBuffer)
|
||||
resultBodyLenPtr := &resultBodyLen
|
||||
resultErrorPtr, resultErrorCapacity := bytesToPtr(resultErrorBuffer)
|
||||
resultErrorLenPtr := &resultErrorLen
|
||||
|
||||
// --- Call the Host Function ---
|
||||
hostReturnCode := http_fetch(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
bodyPtr, bodyLen,
|
||||
timeoutMillis,
|
||||
uint32(uintptr(unsafe.Pointer(resultStatusPtr))),
|
||||
resultBodyPtr, resultBodyCapacity, uint32(uintptr(unsafe.Pointer(resultBodyLenPtr))),
|
||||
resultErrorPtr, resultErrorCapacity, uint32(uintptr(unsafe.Pointer(resultErrorLenPtr))),
|
||||
)
|
||||
|
||||
// --- Process Results ---
|
||||
if hostReturnCode != 0 {
|
||||
return 0, nil, errors.New("host function http_fetch failed internally")
|
||||
}
|
||||
|
||||
statusCode = int(resultStatus)
|
||||
|
||||
if resultErrorLen > 0 {
|
||||
actualErrorLen := min(resultErrorLen, resultErrorCapacity)
|
||||
errMsg := string(resultErrorBuffer[:actualErrorLen])
|
||||
return statusCode, nil, errors.New(errMsg)
|
||||
}
|
||||
|
||||
if resultBodyLen > 0 {
|
||||
actualBodyLen := min(resultBodyLen, resultBodyCapacity)
|
||||
responseBody = make([]byte, actualBodyLen)
|
||||
copy(responseBody, resultBodyBuffer[:actualBodyLen])
|
||||
|
||||
if resultBodyLen > resultBodyCapacity {
|
||||
err = fmt.Errorf("response body truncated: received %d bytes, but actual size was %d", actualBodyLen, resultBodyLen)
|
||||
return statusCode, responseBody, err
|
||||
}
|
||||
return statusCode, responseBody, nil
|
||||
}
|
||||
|
||||
return statusCode, nil, nil
|
||||
}
|
||||
|
||||
// --- Pointer Helper Functions --- (Copied from user prompt)
|
||||
|
||||
func stringToPtr(s string) (ptr uint32, length uint32) {
|
||||
if len(s) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Use unsafe.StringData for potentially safer pointer access in modern Go
|
||||
// Needs Go 1.20+
|
||||
// return uint32(uintptr(unsafe.Pointer(unsafe.StringData(s)))), uint32(len(s))
|
||||
// Fallback to slice conversion for broader compatibility / if StringData isn't available
|
||||
buf := []byte(s)
|
||||
return bytesToPtr(buf)
|
||||
}
|
||||
|
||||
func bytesToPtr(b []byte) (ptr uint32, length uint32) {
|
||||
if len(b) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Use unsafe.SliceData for potentially safer pointer access in modern Go
|
||||
// Needs Go 1.20+
|
||||
// return uint32(uintptr(unsafe.Pointer(unsafe.SliceData(b)))), uint32(len(b))
|
||||
// Fallback for broader compatibility
|
||||
return uint32(uintptr(unsafe.Pointer(&b[0]))), uint32(len(b))
|
||||
}
|
||||
|
||||
func min(a, b uint32) uint32 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// --- WASM Fetcher Implementation ---
|
||||
type wasmFetcher struct{}
|
||||
|
||||
// Ensure wasmFetcher implements Fetcher
|
||||
var _ Fetcher = (*wasmFetcher)(nil)
|
||||
|
||||
// NewFetcher creates the WASM host function fetcher.
|
||||
func NewFetcher() Fetcher {
|
||||
println("Using WASM host fetcher") // Add a log for confirmation
|
||||
return &wasmFetcher{}
|
||||
}
|
||||
|
||||
func (wf *wasmFetcher) Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
|
||||
// Directly call the wrapper provided by the user
|
||||
// Note: Headers like Accept/User-Agent are assumed to be handled by the host
|
||||
// or aren't settable via this interface. If they are critical, the host
|
||||
// function definition (`http_fetch`) would need to be extended.
|
||||
return callHostHTTPFetch(ctx, method, url, requestBody, timeout)
|
||||
}
|
19
core/agents/mcp/mcp-server/go.mod
Normal file
19
core/agents/mcp/mcp-server/go.mod
Normal file
@ -0,0 +1,19 @@
|
||||
module mcp-server
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require github.com/metoro-io/mcp-golang v0.11.0
|
||||
|
||||
require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/invopop/jsonschema v0.12.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
34
core/agents/mcp/mcp-server/go.sum
Normal file
34
core/agents/mcp/mcp-server/go.sum
Normal file
@ -0,0 +1,34 @@
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
|
||||
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
|
||||
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
232
core/agents/mcp/mcp-server/main.go
Normal file
232
core/agents/mcp/mcp-server/main.go
Normal file
@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
mcp_golang "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
)
|
||||
|
||||
type Content struct {
|
||||
Title string `json:"title" jsonschema:"required,description=The title to submit"`
|
||||
Description *string `json:"description" jsonschema:"description=The description to submit"`
|
||||
}
|
||||
type MyFunctionsArguments struct {
|
||||
Submitter string `json:"submitter" jsonschema:"required,description=The name of the thing calling this tool (openai, google, claude, etc)"`
|
||||
Content Content `json:"content" jsonschema:"required,description=The content of the message"`
|
||||
}
|
||||
|
||||
type ArtistBiography struct {
|
||||
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
|
||||
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
|
||||
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
|
||||
}
|
||||
|
||||
type ArtistURLArgs struct {
|
||||
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
|
||||
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
|
||||
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
done := make(chan struct{})
|
||||
|
||||
// Create the appropriate fetcher (native or WASM based on build tags)
|
||||
// The context passed here is just background; specific calls might use request-scoped contexts.
|
||||
fetcher := NewFetcher()
|
||||
|
||||
// --- Command Line Flag Handling ---
|
||||
nameFlag := flag.String("name", "", "Artist name to query directly")
|
||||
mbidFlag := flag.String("mbid", "", "Artist MBID to query directly")
|
||||
flag.Parse()
|
||||
|
||||
if *nameFlag != "" || *mbidFlag != "" {
|
||||
fmt.Println("--- Running Tools Directly ---")
|
||||
|
||||
// Call getArtistBiography
|
||||
fmt.Printf("Calling get_artist_biography (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
|
||||
if *mbidFlag == "" && *nameFlag == "" {
|
||||
fmt.Println(" Error: --mbid or --name is required for get_artist_biography")
|
||||
} else {
|
||||
// Use context.Background for CLI calls
|
||||
bio, bioErr := getArtistBiography(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
|
||||
if bioErr != nil {
|
||||
fmt.Printf(" Error: %v\n", bioErr)
|
||||
} else {
|
||||
fmt.Printf(" Result: %s\n", bio)
|
||||
}
|
||||
}
|
||||
|
||||
// Call getArtistURL
|
||||
fmt.Printf("Calling get_artist_url (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
|
||||
if *mbidFlag == "" && *nameFlag == "" {
|
||||
fmt.Println(" Error: --mbid or --name is required for get_artist_url")
|
||||
} else {
|
||||
urlResult, urlErr := getArtistURL(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
|
||||
if urlErr != nil {
|
||||
fmt.Printf(" Error: %v\n", urlErr)
|
||||
} else {
|
||||
fmt.Printf(" Result: %s\n", urlResult)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("-----------------------------")
|
||||
return // Exit after direct execution
|
||||
}
|
||||
// --- End Command Line Flag Handling ---
|
||||
|
||||
server := mcp_golang.NewServer(stdio.NewStdioServerTransport())
|
||||
err := server.RegisterTool("hello", "Say hello to a person", func(arguments MyFunctionsArguments) (*mcp_golang.ToolResponse, error) {
|
||||
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Submitter))), nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = server.RegisterTool("get_artist_biography", "Get the biography of an artist", func(arguments ArtistBiography) (*mcp_golang.ToolResponse, error) {
|
||||
bio, err := getArtistBiography(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(bio)), nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = server.RegisterTool("get_artist_url", "Get the artist's specific Wikipedia URL via MBID, or a search URL using name as fallback", func(arguments ArtistURLArgs) (*mcp_golang.ToolResponse, error) {
|
||||
urlResult, err := getArtistURL(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(urlResult)), nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = server.RegisterPrompt("prompt_test", "This is a test prompt", func(arguments Content) (*mcp_golang.PromptResponse, error) {
|
||||
return mcp_golang.NewPromptResponse("description", mcp_golang.NewPromptMessage(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Title)), mcp_golang.RoleUser)), nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = server.RegisterResource("test://resource", "resource_test", "This is a test resource", "application/json", func() (*mcp_golang.ResourceResponse, error) {
|
||||
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("test://resource", "This is a test resource", "application/json")), nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = server.RegisterResource("file://app_logs", "app_logs", "The app logs", "text/plain", func() (*mcp_golang.ResourceResponse, error) {
|
||||
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("file://app_logs", "This is a test resource", "text/plain")), nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = server.Serve()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
func getArtistBiography(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if mbid == "" {
|
||||
fmt.Fprintf(os.Stderr, "MBID not provided, attempting DBpedia lookup by name: %s\n", name)
|
||||
} else {
|
||||
// 1. Attempt Wikidata MBID lookup first
|
||||
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
|
||||
if err == nil {
|
||||
// 1a. Found Wikidata URL, now fetch from Wikipedia API
|
||||
bio, errBio := GetBioFromWikipediaAPI(fetcher, ctx, wikiURL)
|
||||
if errBio == nil {
|
||||
return bio, nil // Success via Wikidata/Wikipedia!
|
||||
} else {
|
||||
// Failed to get bio even though URL was found
|
||||
fmt.Fprintf(os.Stderr, "Found Wikipedia URL (%s) via MBID %s, but failed to fetch bio: %v\n", wikiURL, mbid, errBio)
|
||||
// Fall through to try DBpedia by name as a last resort?
|
||||
// Let's fall through for now.
|
||||
}
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
// Wikidata lookup failed for a reason other than not found (e.g., network)
|
||||
fmt.Fprintf(os.Stderr, "Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
|
||||
// Don't proceed to DBpedia name lookup if Wikidata had a technical failure
|
||||
return "", fmt.Errorf("Wikidata lookup failed: %w", err)
|
||||
} else {
|
||||
// Wikidata lookup returned ErrNotFound for MBID
|
||||
fmt.Fprintf(os.Stderr, "MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attempt DBpedia lookup by name (if MBID was missing or failed with ErrNotFound)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("cannot find artist: MBID lookup failed or MBID not provided, and no name provided for DBpedia fallback")
|
||||
}
|
||||
dbpediaBio, errDb := GetArtistBioFromDBpedia(fetcher, ctx, name)
|
||||
if errDb == nil {
|
||||
return dbpediaBio, nil // Success via DBpedia!
|
||||
}
|
||||
|
||||
// 3. If both Wikidata (MBID) and DBpedia (Name) failed
|
||||
if errors.Is(errDb, ErrNotFound) {
|
||||
return "", fmt.Errorf("artist '%s' (MBID: %s) not found via Wikidata MBID or DBpedia Name lookup", name, mbid)
|
||||
}
|
||||
|
||||
// Return DBpedia's error if it wasn't ErrNotFound
|
||||
return "", fmt.Errorf("DBpedia lookup failed for name '%s': %w", name, errDb)
|
||||
}
|
||||
|
||||
// getArtistURL attempts to find the specific Wikipedia URL using MBID (via Wikidata),
|
||||
// then by Name (via DBpedia), falling back to a search URL using name.
|
||||
func getArtistURL(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if mbid == "" {
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: MBID not provided, attempting DBpedia lookup by name: %s\n", name)
|
||||
} else {
|
||||
// Try to get the specific URL from Wikidata using MBID
|
||||
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
|
||||
if err == nil && wikiURL != "" {
|
||||
return wikiURL, nil // Found specific URL via MBID
|
||||
}
|
||||
// Log error if Wikidata lookup failed for reasons other than not found
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
|
||||
// Fall through to try DBpedia if name is available
|
||||
} else if errors.Is(err, ErrNotFound) {
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 1: Try DBpedia lookup by name
|
||||
if name != "" {
|
||||
dbpediaWikiURL, errDb := GetArtistWikipediaURLFromDBpedia(fetcher, ctx, name)
|
||||
if errDb == nil && dbpediaWikiURL != "" {
|
||||
return dbpediaWikiURL, nil // Found specific URL via DBpedia Name lookup
|
||||
}
|
||||
// Log error if DBpedia lookup failed for reasons other than not found
|
||||
if errDb != nil && !errors.Is(errDb, ErrNotFound) {
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: DBpedia URL lookup failed for name '%s' (non-NotFound error): %v\n", name, errDb)
|
||||
// Fall through to search URL fallback
|
||||
} else if errors.Is(errDb, ErrNotFound) {
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: Name '%s' not found on DBpedia, attempting search fallback\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 2: Generate a search URL if name is provided
|
||||
if name != "" {
|
||||
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(name))
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: Falling back to search URL: %s\n", searchURL)
|
||||
return searchURL, nil
|
||||
}
|
||||
|
||||
// Final error: MBID lookup failed (or no MBID given) AND no name provided for fallback
|
||||
return "", fmt.Errorf("cannot generate Wikipedia URL: Wikidata/DBpedia lookups failed and no artist name provided for search fallback")
|
||||
}
|
169
core/agents/mcp/mcp-server/wikidata.go
Normal file
169
core/agents/mcp/mcp-server/wikidata.go
Normal file
@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wikidataEndpoint = "https://query.wikidata.org/sparql"
|
||||
|
||||
// ErrNotFound indicates a specific item (like an artist or URL) was not found on Wikidata.
|
||||
var ErrNotFound = errors.New("item not found on Wikidata")
|
||||
|
||||
// Wikidata SPARQL query result structures
|
||||
type SparqlResult struct {
|
||||
Results SparqlBindings `json:"results"`
|
||||
}
|
||||
|
||||
type SparqlBindings struct {
|
||||
Bindings []map[string]SparqlValue `json:"bindings"`
|
||||
}
|
||||
|
||||
type SparqlValue struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Lang string `json:"xml:lang,omitempty"` // Handle language tags like "en"
|
||||
}
|
||||
|
||||
// GetArtistBioFromWikidata queries Wikidata for an artist's description using their MBID.
|
||||
func GetArtistBioFromWikidata(client *http.Client, mbid string) (string, error) {
|
||||
if mbid == "" {
|
||||
return "", fmt.Errorf("MBID is required to query Wikidata")
|
||||
}
|
||||
|
||||
// SPARQL query to find the English description for an entity with a specific MusicBrainz ID
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
SELECT ?artistDescription WHERE {
|
||||
?artist wdt:P434 "%s" . # P434 is the property for MusicBrainz artist ID
|
||||
OPTIONAL {
|
||||
?artist schema:description ?artistDescription .
|
||||
FILTER(LANG(?artistDescription) = "en")
|
||||
}
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
}
|
||||
LIMIT 1`, mbid)
|
||||
|
||||
// Prepare the HTTP request
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "json")
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create Wikidata request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/sparql-results+json")
|
||||
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (https://example.com/contact)") // Good practice to identify your client
|
||||
|
||||
// Execute the request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Attempt to read body for more error info, but don't fail if it doesn't work
|
||||
bodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
errorMsg := "Could not read error body"
|
||||
if readErr == nil {
|
||||
errorMsg = string(bodyBytes)
|
||||
}
|
||||
return "", fmt.Errorf("Wikidata query failed with status %d: %s", resp.StatusCode, errorMsg)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var result SparqlResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
|
||||
}
|
||||
|
||||
// Extract the description
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if descriptionVal, ok := result.Results.Bindings[0]["artistDescription"]; ok {
|
||||
return descriptionVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no English description found on Wikidata for MBID %s", mbid)
|
||||
}
|
||||
|
||||
// GetArtistWikipediaURL queries Wikidata for an artist's English Wikipedia page URL using MBID.
|
||||
// It tries searching by MBID first, then falls back to searching by name.
|
||||
func GetArtistWikipediaURL(fetcher Fetcher, ctx context.Context, mbid string) (string, error) {
|
||||
// 1. Try finding by MBID
|
||||
if mbid == "" {
|
||||
return "", fmt.Errorf("MBID is required to find Wikipedia URL on Wikidata")
|
||||
} else {
|
||||
// SPARQL query to find the enwiki URL for an entity with a specific MusicBrainz ID
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
SELECT ?article WHERE {
|
||||
?artist wdt:P434 "%s" . # P434 is MusicBrainz artist ID
|
||||
?article schema:about ?artist ;
|
||||
schema:isPartOf <https://en.wikipedia.org/> .
|
||||
}
|
||||
LIMIT 1`, mbid)
|
||||
|
||||
foundURL, err := executeWikidataURLQuery(fetcher, ctx, sparqlQuery)
|
||||
if err == nil && foundURL != "" {
|
||||
return foundURL, nil // Found via MBID
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Wikidata URL lookup via MBID %s failed: %v\n", mbid, err)
|
||||
return "", fmt.Errorf("Wikidata URL lookup via MBID failed: %w", err)
|
||||
}
|
||||
|
||||
// Should not be reached if MBID is provided
|
||||
return "", fmt.Errorf("internal error: reached end of GetArtistWikipediaURL unexpectedly")
|
||||
}
|
||||
|
||||
// executeWikidataURLQuery is a helper to run SPARQL and extract the first bound URL for '?article'.
|
||||
func executeWikidataURLQuery(fetcher Fetcher, ctx context.Context, sparqlQuery string) (string, error) {
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "json")
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
|
||||
|
||||
// Directly use the fetcher
|
||||
// Note: Headers (Accept, User-Agent) are now handled by the Fetcher implementation
|
||||
// The WASM fetcher currently doesn't support setting them via the host func interface.
|
||||
// Timeout is handled via context for native, and passed to host func for WASM.
|
||||
// Let's use a default timeout here if not provided via context (e.g., 15s)
|
||||
// TODO: Consider making timeout configurable or passed down
|
||||
timeout := 15 * time.Second
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
|
||||
}
|
||||
|
||||
// Check status code. Fetcher interface implies body might be returned even on error.
|
||||
if statusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Wikidata query failed with status %d: %s", statusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result SparqlResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil { // Use Unmarshal for byte slice
|
||||
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if articleVal, ok := result.Results.Bindings[0]["article"]; ok {
|
||||
return articleVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNotFound
|
||||
}
|
108
core/agents/mcp/mcp-server/wikipedia.go
Normal file
108
core/agents/mcp/mcp-server/wikipedia.go
Normal file
@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mediaWikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
|
||||
|
||||
// Structures for parsing MediaWiki API response (query extracts)
|
||||
type MediaWikiQueryResult struct {
|
||||
Query MediaWikiQuery `json:"query"`
|
||||
}
|
||||
|
||||
type MediaWikiQuery struct {
|
||||
Pages map[string]MediaWikiPage `json:"pages"`
|
||||
}
|
||||
|
||||
type MediaWikiPage struct {
|
||||
PageID int `json:"pageid"`
|
||||
Ns int `json:"ns"`
|
||||
Title string `json:"title"`
|
||||
Extract string `json:"extract"`
|
||||
}
|
||||
|
||||
// Default timeout for Wikipedia API requests
|
||||
const defaultWikipediaTimeout = 15 * time.Second
|
||||
|
||||
// GetBioFromWikipediaAPI fetches the introductory text of a Wikipedia page.
|
||||
func GetBioFromWikipediaAPI(fetcher Fetcher, ctx context.Context, wikipediaURL string) (string, error) {
|
||||
pageTitle, err := extractPageTitleFromURL(wikipediaURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not extract title from Wikipedia URL %s: %w", wikipediaURL, err)
|
||||
}
|
||||
|
||||
// Prepare API request parameters
|
||||
apiParams := url.Values{}
|
||||
apiParams.Set("action", "query")
|
||||
apiParams.Set("format", "json")
|
||||
apiParams.Set("prop", "extracts") // Request page extracts
|
||||
apiParams.Set("exintro", "true") // Get only the intro section
|
||||
apiParams.Set("explaintext", "true") // Get plain text instead of HTML
|
||||
apiParams.Set("titles", pageTitle) // Specify the page title
|
||||
apiParams.Set("redirects", "1") // Follow redirects
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", mediaWikiAPIEndpoint, apiParams.Encode())
|
||||
|
||||
timeout := defaultWikipediaTimeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute MediaWiki request for title '%s': %w", pageTitle, err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("MediaWiki query for '%s' failed with status %d: %s", pageTitle, statusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var result MediaWikiQueryResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode MediaWiki response for '%s': %w", pageTitle, err)
|
||||
}
|
||||
|
||||
// Extract the text - MediaWiki API returns pages keyed by page ID
|
||||
for _, page := range result.Query.Pages {
|
||||
if page.Extract != "" {
|
||||
// Often includes a newline at the end, trim it
|
||||
return strings.TrimSpace(page.Extract), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no extract found in MediaWiki response for title '%s' (page might not exist or be empty)", pageTitle)
|
||||
}
|
||||
|
||||
// extractPageTitleFromURL attempts to get the page title from a standard Wikipedia URL.
|
||||
// Example: https://en.wikipedia.org/wiki/The_Beatles -> The_Beatles
|
||||
func extractPageTitleFromURL(wikiURL string) (string, error) {
|
||||
parsedURL, err := url.Parse(wikiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsedURL.Host != "en.wikipedia.org" {
|
||||
return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host)
|
||||
}
|
||||
pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/")
|
||||
if len(pathParts) < 2 || pathParts[0] != "wiki" {
|
||||
return "", fmt.Errorf("URL path does not match /wiki/<title> format: %s", parsedURL.Path)
|
||||
}
|
||||
title := pathParts[1]
|
||||
if title == "" {
|
||||
return "", fmt.Errorf("extracted title is empty")
|
||||
}
|
||||
// URL Decode the title (e.g., %27 -> ')
|
||||
decodedTitle, err := url.PathUnescape(title)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode title '%s': %w", title, err)
|
||||
}
|
||||
return decodedTitle, nil
|
||||
}
|
@ -27,7 +27,7 @@ import (
|
||||
// Exported constants for testing
|
||||
const (
|
||||
McpAgentName = "mcp"
|
||||
McpServerPath = "/Users/deluan/Development/navidrome/plugins-mcp/mcp-server.wasm"
|
||||
McpServerPath = "./core/agents/mcp/mcp-server/mcp-server.wasm"
|
||||
McpToolNameGetBio = "get_artist_biography"
|
||||
McpToolNameGetURL = "get_artist_url"
|
||||
initializationTimeout = 10 * time.Second
|
||||
|
1
mcp-server/README.md
Normal file
1
mcp-server/README.md
Normal file
@ -0,0 +1 @@
|
||||
|
Loading…
x
Reference in New Issue
Block a user