Tool text that lied to agents: - gnubok_create_voucher pointed at gnubok_reverse_entry, which does not exist; the tool is gnubok_reverse_journal_entry. A scan of server.ts, skills/, prompts/ and structured-errors.ts found no other phantom names. - gnubok_reverse_journal_entry said reversal_date defaults to today; the executor passes undefined and reverseEntry() uses the original entry date (same as the dashboard). Description now states that. No behaviour change. - gnubok_get_vacation_balance promised an estimated semesterloneskuld in SEK but returned none. The tool now returns estimated_liability_sek using the same BFNAR 2016:10 day valuation as the year-close and the v1 vacation-balance route (dayValueSek exported from semesterberedning), floored at zero for overdrawn balances. Descriptions trimmed so the tools/list payload stays under the 60.7K-token ceiling (60,696 after). - gnubok_create_invoice said the invoice number is assigned at approval; it is assigned on send or mark-as-sent (ensureInvoiceNumber). - gnubok_convert_invoice: "har redan makuleras" -> "har redan makulerats". - lib/entitlements/keys.ts comment claimed bank_sync has no MCP tool while the map right below gates gnubok_connect_bank on it. Version: MCP serverInfo.version, the extension version and /api/health all hardcoded '1.0.0', so clients could not tell deploys apart. They now share currentAppVersion() (commit SHA prefix inlined at build), resolved once at module load so the definitions layer stays deterministic, with '1.0.0' as the self-hosted fallback so Docker healthchecks keep a value. serverInfo is not part of tools/list, so the catalog payload is unaffected by this part. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
87 lines
3.5 KiB
TypeScript
87 lines
3.5 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { Extension } from '@/lib/extensions/types'
|
|
import { buildProtectedResourceMetadata } from '@/lib/auth/protected-resource-metadata'
|
|
import { handleMcpRequest, tools as mcpTools } from './server'
|
|
import { isForbiddenOrigin, forbiddenOriginResponse } from './origin-guard'
|
|
import { registerAgentTools } from '@/lib/agent/tools/registry'
|
|
import type { AgentTool } from '@/lib/agent/tools/types'
|
|
import { currentAppVersion } from '@/lib/reports/app-version'
|
|
|
|
// Make the same tool set available to the in-app chat agent. The chat loop
|
|
// (lib/agent/chat/*) dispatches against the core agentToolRegistry so it can
|
|
// stay decoupled from this extension's module path. Tools satisfy the
|
|
// AgentTool contract structurally: see lib/agent/tools/types.ts.
|
|
registerAgentTools(mcpTools as unknown as AgentTool[])
|
|
|
|
export const mcpServerExtension: Extension = {
|
|
id: 'mcp-server',
|
|
name: 'MCP Server',
|
|
// Build-derived so deploys are distinguishable; '1.0.0' when self-hosted
|
|
// without an inlined commit SHA. Same identifier as serverInfo.version.
|
|
version: currentAppVersion() ?? '1.0.0',
|
|
|
|
settingsPanel: {
|
|
label: 'MCP-server (API)',
|
|
path: '/settings/api',
|
|
},
|
|
|
|
apiRoutes: [
|
|
{
|
|
method: 'POST',
|
|
path: '/mcp',
|
|
skipAuth: true, // Auth handled via API key in the handler
|
|
handler: async (request: Request) => {
|
|
// MCP spec MUST: validate Origin (DNS-rebinding defense). See origin-guard.ts.
|
|
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
|
|
return handleMcpRequest(request)
|
|
},
|
|
},
|
|
// MCP Streamable HTTP also needs GET for SSE and DELETE for session termination
|
|
{
|
|
method: 'GET',
|
|
path: '/mcp',
|
|
skipAuth: true,
|
|
// This server is stateless and offers no server-initiated SSE stream, so
|
|
// the Streamable HTTP spec requires 405 Method Not Allowed here. Returning
|
|
// 401 (as we previously did) makes spec-compliant clients (Claude
|
|
// connector, Claude Desktop, Cursor) treat the SSE GET as an auth failure
|
|
// and retry-loop: refresh token → re-open GET → 401 → …: which storms
|
|
// the endpoint and churns OAuth key rotation. OAuth discovery is
|
|
// bootstrapped on the POST 401 (WWW-Authenticate), not here.
|
|
handler: async (request: Request) => {
|
|
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
|
|
return new Response('Method Not Allowed', {
|
|
status: 405,
|
|
headers: { Allow: 'POST, DELETE' },
|
|
})
|
|
},
|
|
},
|
|
{
|
|
method: 'DELETE',
|
|
path: '/mcp',
|
|
skipAuth: true,
|
|
// Stateless: no sessions to terminate
|
|
handler: async (request: Request) => {
|
|
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
|
|
return new Response(null, { status: 204 })
|
|
},
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/mcp/.well-known/oauth-protected-resource',
|
|
skipAuth: true,
|
|
// Endpoint-appended RFC 9728 discovery. Claude.ai's connector setup
|
|
// derives the metadata URL from the server URL and tries both the
|
|
// path-based root form and this one before any 401; a 404 here reads
|
|
// as "Authorization failed". Public by nature: it names the
|
|
// authorization server and nothing tenant-specific.
|
|
handler: async (request: Request) => {
|
|
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
|
|
return NextResponse.json(buildProtectedResourceMetadata(request))
|
|
},
|
|
},
|
|
],
|
|
|
|
eventHandlers: [],
|
|
}
|