Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "proxy-smart-backend",
"displayName": "Proxy Smart Backend",
"version": "0.3.7-beta.202608090758.b67184cdc",
"version": "0.3.7-alpha.202608091327.e5f89d24b",
"type": "module",
"scripts": {
"test": "bun test --isolate",
Expand Down
2 changes: 1 addition & 1 deletion deploy/infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "proxy-smart-infra",
"displayName": "Proxy Smart Infrastructure",
"description": "AWS CDK infrastructure for Proxy Smart production deployment",
"version": "0.3.7-beta.202608090758.b67184cdc",
"version": "0.3.7-alpha.202608091327.e5f89d24b",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "proxy-smart",
"version": "0.3.7-beta.202608090758.b67184cdc",
"version": "0.3.7-alpha.202608091327.e5f89d24b",
"repository": {
"type": "git",
"url": "git+https://github.com/Max-Health-Inc/proxy-smart.git"
Expand Down
2 changes: 2 additions & 0 deletions packages/elysia-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export {
extractResponseSchema,
annotationsForMethod,
pathToToolName,
uniqueToolName,
MAX_TOOL_NAME_LENGTH,
pathToResourceName,
pathToResourceUri,
} from './introspect'
Expand Down
90 changes: 77 additions & 13 deletions packages/elysia-mcp/src/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function extractRouteTools(app: unknown, options?: IntrospectOptions): Ma
const responseSchema = extractResponseSchema(hooks?.response ?? legacySchema?.response)

const isGet = method === 'GET'
const toolName = nameGen(path, method)
const toolName = uniqueToolName(nameGen(path, method), path, method, new Set(tools.keys()))

tools.set(toolName, {
path,
Expand Down Expand Up @@ -197,6 +197,43 @@ export function extractRouteResources(app: unknown, options?: IntrospectOptions)

// ── Naming helpers ───────────────────────────────────────────────────────────

/**
* Longest tool name a client will accept.
*
* Tool names are constrained to `^[a-zA-Z0-9_-]{1,64}$`. A name over the cap is not
* truncated by the client — the whole tool is REJECTED, and silently as far as the
* server is concerned. Deep admin paths cross 64 easily: three of this surface's
* routes produced 66- and 67-character names and were dropped from every session,
* with nothing in the server's own tool listing to show for it.
*/
export const MAX_TOOL_NAME_LENGTH = 64

const METHOD_PREFIXES: Record<string, string> = {
GET: 'get',
POST: 'create',
PUT: 'update',
PATCH: 'update',
DELETE: 'delete',
}

/** A short, stable digest — enough to separate names that would otherwise coincide. */
function digest(input: string): string {
// FNV-1a. No crypto import for a disambiguator, and it must stay identical across
// runtimes: a tool name that changes between deploys breaks saved client prompts.
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash.toString(36).padStart(7, '0').slice(0, 7)
}

/** Cut a name to the cap, keeping a digest of the original so it stays unique. */
function truncateWithDigest(name: string): string {
const suffix = `_${digest(name)}`
return name.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length) + suffix
}

/**
* Convert route path and method to a tool name.
*
Expand All @@ -205,21 +242,48 @@ export function extractRouteResources(app: unknown, options?: IntrospectOptions)
* - PUT /admin/users/:id -> update_admin_users_id
* - DELETE /admin/roles/:roleName -> delete_admin_roles_roleName
* - GET /admin/branding -> get_admin_branding
*
* Over {@link MAX_TOOL_NAME_LENGTH}, path PARAMETERS are dropped first:
* - DELETE /admin/healthcare-users/:userId/client-roles/:clientId/:roleName
* -> delete_admin_healthcare-users_client-roles
* They are the least informative part of a name — every one of them is already an
* argument in the tool's input schema, described there — so shedding them costs a
* reader nothing while keeping the segments that say what the tool acts on. Only if
* that still does not fit is the name cut and digested.
*
* Names at or under the cap are returned exactly as before, so shortening can never
* rename a tool that was already being served.
*/
export function pathToToolName(path: string, method: string): string {
let name = path.replace(/^\//, '')
name = name.replace(/\//g, '_').replace(/:/g, '')

const methodPrefixes: Record<string, string> = {
GET: 'get',
POST: 'create',
PUT: 'update',
PATCH: 'update',
DELETE: 'delete',
}
const prefix = METHOD_PREFIXES[method.toUpperCase()] ?? method.toLowerCase()

// Verbatim original construction, empty segments included. Several routes are
// declared with a trailing slash, so their names legitimately end in `_`
// (`get_admin_profile_`); normalising that away here would rename 30 tools that
// clients call today, to fix 3 they cannot see.
const full = `${prefix}_${path.replace(/^\//, '').replace(/\//g, '_').replace(/:/g, '')}`
if (full.length <= MAX_TOOL_NAME_LENGTH) return full

const kept = path.split('/').filter((s) => s.length > 0 && !s.startsWith(':'))
const shortened = `${prefix}_${kept.join('_')}`
if (kept.length > 0 && shortened.length <= MAX_TOOL_NAME_LENGTH) return shortened

return truncateWithDigest(full)
}

/**
* A name not already taken, disambiguated by digesting the route it came from.
*
* Dropping parameters can make two routes agree on a name, and the registry is a Map
* keyed by name: without this the second route would overwrite the first and one tool
* would vanish with no error anywhere.
*/
export function uniqueToolName(candidate: string, path: string, method: string, taken: ReadonlySet<string>): string {
if (!taken.has(candidate)) return candidate

const prefix = methodPrefixes[method.toUpperCase()] ?? method.toLowerCase()
return `${prefix}_${name}`
const suffix = `_${digest(`${method} ${path}`)}`
const base = candidate.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length)
return `${base}${suffix}`
}

/**
Expand Down
140 changes: 140 additions & 0 deletions packages/elysia-mcp/test/tool-name-length.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: Max Health Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Commercial

/**
* Tool names must fit the client's cap, or the tool is dropped.
*
* THE DEFECT THIS PINS. Tool names are constrained to `^[a-zA-Z0-9_-]{1,64}$`, and a
* name over the cap is not truncated — the whole tool is rejected. Nothing on the
* server side notices: the registry lists it, the route works over HTTP, and it is
* simply absent from every session. Three admin routes generated 66- and 67-character
* names this way:
*
* delete_admin_healthcare-users_userId_client-roles_clientId_roleName (67)
* create_admin_healthcare-users_userId_federated-identities_provider (66)
* delete_admin_healthcare-users_userId_federated-identities_provider (66)
*
* The margin is thin — the longest surviving name was 61 — so this is a boundary the
* surface will keep crossing as paths get deeper. Hence a property test over generated
* paths rather than three literal cases.
*/

import { describe, it, expect } from 'bun:test'
import { Elysia, t } from 'elysia'
import {
extractRouteTools,
pathToToolName,
uniqueToolName,
MAX_TOOL_NAME_LENGTH,
} from '../src/index'

const VALID_TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/

/** The three real routes whose names were over the cap. */
const REGRESSION_ROUTES: Array<[string, string]> = [
['DELETE', '/admin/healthcare-users/:userId/client-roles/:clientId/:roleName'],
['POST', '/admin/healthcare-users/:userId/federated-identities/:provider'],
['DELETE', '/admin/healthcare-users/:userId/federated-identities/:provider'],
]

describe('pathToToolName length cap', () => {
it('produced names a client rejects, before the cap existed', () => {
// The pre-fix algorithm, kept literal so the regression cannot be argued away.
const legacy = (path: string, prefix: string) =>
`${prefix}_${path.replace(/^\//, '').replace(/\//g, '_').replace(/:/g, '')}`

expect(legacy(REGRESSION_ROUTES[0][1], 'delete').length).toBe(67)
expect(legacy(REGRESSION_ROUTES[1][1], 'create').length).toBe(66)
expect(legacy(REGRESSION_ROUTES[2][1], 'delete').length).toBe(66)
})

it.each(REGRESSION_ROUTES)('keeps %s %s within the cap', (method, path) => {
const name = pathToToolName(path, method)
expect(name.length).toBeLessThanOrEqual(MAX_TOOL_NAME_LENGTH)
expect(name).toMatch(VALID_TOOL_NAME)
})

it('drops path parameters first, keeping the segments that say what it acts on', () => {
expect(pathToToolName('/admin/healthcare-users/:userId/client-roles/:clientId/:roleName', 'DELETE'))
.toBe('delete_admin_healthcare-users_client-roles')
expect(pathToToolName('/admin/healthcare-users/:userId/federated-identities/:provider', 'POST'))
.toBe('create_admin_healthcare-users_federated-identities')
})

it('leaves a name that already fits exactly as it was', () => {
// Shortening must never rename a tool clients are already calling.
expect(pathToToolName('/admin/users', 'POST')).toBe('create_admin_users')
expect(pathToToolName('/admin/roles/:roleName', 'DELETE')).toBe('delete_admin_roles_roleName')
expect(pathToToolName('/admin/auth-flows/executions/:executionId/raise-priority', 'POST'))
.toBe('create_admin_auth-flows_executions_executionId_raise-priority')
})

it('keeps the trailing underscore of a trailing-slash route', () => {
// Many routes are declared as '/admin/profile/', so their live names end in '_'.
// Tidying that up would rename 30 working tools to fix 3 unreachable ones.
expect(pathToToolName('/admin/profile/', 'GET')).toBe('get_admin_profile_')
expect(pathToToolName('/admin/smart-apps/', 'POST')).toBe('create_admin_smart-apps_')
})

it('falls back to a digest when even a parameterless path is too long', () => {
const long = '/admin/' + Array.from({ length: 12 }, (_, i) => `segment-number-${i}`).join('/')
const name = pathToToolName(long, 'POST')
expect(name.length).toBe(MAX_TOOL_NAME_LENGTH)
expect(name).toMatch(VALID_TOOL_NAME)
})

it('is deterministic across calls, so a redeploy does not rename tools', () => {
const long = '/admin/' + Array.from({ length: 12 }, (_, i) => `segment-number-${i}`).join('/')
expect(pathToToolName(long, 'POST')).toBe(pathToToolName(long, 'POST'))
})

it('distinguishes paths that only differ past the truncation point', () => {
const base = '/admin/' + Array.from({ length: 12 }, (_, i) => `segment-number-${i}`).join('/')
expect(pathToToolName(`${base}/alpha`, 'POST')).not.toBe(pathToToolName(`${base}/beta`, 'POST'))
})
})

describe('uniqueToolName', () => {
it('returns the candidate when it is free', () => {
expect(uniqueToolName('create_admin_users', '/admin/users', 'POST', new Set())).toBe('create_admin_users')
})

it('disambiguates rather than letting one tool overwrite another', () => {
const taken = new Set(['delete_admin_healthcare-users_client-roles'])
const name = uniqueToolName(
'delete_admin_healthcare-users_client-roles',
'/admin/healthcare-users/:userId/client-roles',
'DELETE',
taken,
)
expect(name).not.toBe('delete_admin_healthcare-users_client-roles')
expect(name.length).toBeLessThanOrEqual(MAX_TOOL_NAME_LENGTH)
expect(name).toMatch(VALID_TOOL_NAME)
})
})

describe('extractRouteTools over a route table', () => {
it('registers every route, and every name is client-acceptable', () => {
const app = new Elysia()
.get('/admin/branding', () => ({}))
.post('/admin/healthcare-users/:userId/federated-identities/:provider', () => ({}), {
body: t.Object({ userId: t.String() }),
})
.delete('/admin/healthcare-users/:userId/federated-identities/:provider', () => ({}))
.delete('/admin/healthcare-users/:userId/client-roles/:clientId/:roleName', () => ({}))
.delete('/admin/healthcare-users/:userId/client-roles', () => ({}))

const tools = extractRouteTools(app, { prefixes: ['/admin/'] })

// Five routes in, five tools out: none silently lost to a name collision.
expect(tools.size).toBe(5)
for (const name of tools.keys()) {
expect(name).toMatch(VALID_TOOL_NAME)
}

// Every registered route is still reachable under some name.
const paths = [...tools.values()].map((tool) => `${tool.method} ${tool.path}`)
expect(paths).toContain('DELETE /admin/healthcare-users/:userId/client-roles/:clientId/:roleName')
expect(paths).toContain('DELETE /admin/healthcare-users/:userId/client-roles')
})
})
2 changes: 1 addition & 1 deletion scripts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "proxy-smart-scripts",
"version": "0.3.7-beta.202608090758.b67184cdc",
"version": "0.3.7-alpha.202608091327.e5f89d24b",
"description": "CI/CD and development scripts for Proxy Smart",
"private": true,
"type": "module",
Expand Down